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.