axon
The axon global is available in every script, tool and route. It is the runtime
interface between your code and the agent loop.
interface AxonHandle {
request(input: string | RequestOptions): Promise<RequestResult>
stream(input: string | RequestOptions): { stream: AsyncGenerator<AnyThreadEntry> }
prompt(name: string, vars?: Record<string, unknown>): Promise<RenderedPrompt>
scripts: {
request(name: string, args?: object): Promise<RequestResult>
stream(name: string, args?: object): { stream: AsyncGenerator<AnyThreadEntry> }
}
tools: Record<string, Record<string, (...args: never[]) => Promise<unknown>>>
ui: {
ask(opts: { message: string; options: string[] }): Promise<AskResponse>
}
}
No import. axon prepare generates the types, so the whole surface autocompletes.
request
Sends a prompt to the agent loop and waits for the turn to finish.
const result = await axon.request("summarise the open issues")
result.text // the agent's final message, as a string
result.entries // the full typed timeline for that turn
With options:
const result = await axon.request({
prompt: "summarise the open issues",
policy: { ... }, // narrow the active policy for this call only
})
Use request when you need the whole result before continuing — extracting text,
passing it on, writing it to a file:
const { domain } = defineArgs<{ domain: string }>()
const learnPrompt = await axon.prompt("learn", { domain })
const result = await axon.request({ prompt: learnPrompt })
const match = result.text.match(/```knowledge\n([\s\S]*?)```/)
if (match) {
await writeFile(`data/knowledge/${domain}.md`, match[1].trim())
}
prompt accepts an array. Axon concatenates in order — the standard pattern is
session context first, task second:
const context = await axon.prompt("context")
const task = await axon.prompt("close-plan", { issueId })
const result = await axon.request({ prompt: [context, task] })
stream
The same call, yielding entries as they arrive instead of waiting.
const { stream } = axon.stream("scout the repository and report findings")
for await (const entry of stream) {
if (entry.type === "axon:agent:message:delta") {
process.stdout.write(entry.payload.content)
}
}
Takes the same options object as request. Use it when forwarding output
incrementally — chat routes, long tasks, progress display. The loop may run for
seconds or minutes, and streaming shows work rather than blocking until done.
Forwarding from a route is the common case:
// server/api/chat.ts
export default defineEventHandler(async (event) => {
const { message } = await readBody(event)
const { stream } = axon.stream(message)
return sendStream(event, stream)
})
Deltas arrive as the model generates; the final axon:agent:message carries the
accumulated content. Renderers typically append deltas in place and replace with the
final entry. Every entry type is on events.
prompt
Loads and renders a prompt from src/prompts/, ready to pass to request or stream.
const p = await axon.prompt("session")
const result = await axon.request({ prompt: p })
Prompts are Vuedown files and can declare variables in a <script setup> block. Pass
them as the second argument and they are interpolated at render time — the agent
receives fully rendered text.
const review = await axon.prompt("code-review", {
issueId: "bd-42",
repository: "arclabs/axon",
})
scripts
Invoke another script from a script or a route. The target runs in the same agent instance — same tools, same session, same conversation.
const result = await axon.scripts.request("close-plan", { issueId: "bd-yiq" })
const { stream } = axon.scripts.stream("scout")
request and stream behave exactly as their top-level counterparts. Arguments arrive
in the target through defineArgs:
// src/scripts/close-plan.ts
const { issueId } = defineArgs<{ issueId: string }>()
This is what keeps route handlers thin — the logic lives in the script, the route wires HTTP to it:
// server/api/close.ts
export default defineEventHandler(async (event) => {
const { issueId } = await readBody(event)
const { stream } = axon.scripts.stream("close-plan", { issueId })
return sendStream(event, stream)
})
tools
Tool exports are globals. Call them directly — no prefix, no namespace wrapper.
const tasks = await kanban.list("open")
Each top-level export from src/tools/*.ts lands on the global scope under its exact
name, so src/tools/kanban.ts exporting kanban gives you kanban, and a file
exporting now and format gives you both. Installed modules keep their namespace:
@axon/github contributes github.openPr, never a bare openPr.
Tools execute in the capsule — the mediated scope — and the call is direct, handled for you. Every call is awaited, whether you wrote the function sync or async, because each one is policy-checked before the body runs and a rule may escalate to the user for approval. That round trip cannot be synchronous.
const sum = await add(2, 3)
The same functions are always reachable explicitly:
const tasks = await axon.tools.kanban.list("open")
Identical behaviour — same capsule, same policy, same tracing. The globals are bindings
onto this surface, not a separate route. Reach for the explicit path when a bare name
would be ambiguous or unavailable: a tool called fetch will not shadow the real
fetch, and stays callable as axon.tools.<file>.fetch.
Typing. axon prepare generates .agent/tool-globals.d.ts — a declare global
block declaring every export, with types read from your source. It mirrors the scope the
model receives, so what your editor tells you and what the agent can call never
disagree. Re-run it after adding or changing a tool file.
Loading state explicitly before a prompt is more reliable than letting the agent fetch it — you control exactly what it sees:
const issues = await kanban.list({ status: "open" })
const session = await axon.prompt("session", { issues })
const { stream } = axon.stream({ prompt: session })
ui
Requests input from a connected TUI host. When the agent runs headlessly — axon <ref> -s <script>, a
cron job, a route handler — it returns { unavailable } rather than blocking.
const response = await axon.ui.ask({
message: "Proceed with the migration?",
options: ["yes", "no", "show me the diff first"],
})
if ("unavailable" in response) {
// no host attached — fall back to a default
} else if ("timeout" in response) {
// nobody answered in time
} else {
console.log(response.selected)
}
The unavailable check is the contract: any script calling axon.ui must handle the
headless case explicitly. The runtime never blocks waiting for input that may never
come — which is what lets the same script run under a human and under cron.
See also
process — running commands from inside the capsule.
events — every entry type a turn can emit.
axon.config.ts — engine, modules, policy, server.