Anatomy of zero

@axon/zero is the default cognet — the brain every agent runs unless it selects another. It is about 120 lines, and it is the thing you clone when you want to build your own.

axon clone @axon/zero

This page reads all of it.

What it is

A conversational agent loop: fold the log, render context, call one engine, decide per block what becomes real, repeat until the model says it's done and nothing is pending.

Three files.

zero/
├── src/
   ├── main.ts       # the loop
   └── state.ts      # the resident model
├── plugins/
   └── setup.ts      # boot hydration, shutdown checkpoint
└── cognet.config.ts  # identity

Identity

export default defineCognet({
    name: "zero",
    version: "0.1.0",
    abi: "9",

    mode: { kind: "invocation" },

    // runaway guard: a wake that hasn't converged in this many
    // render→infer→act ticks is a loop bug or a stuck model, not progress
    maxTicksPerWake: 32,
})

Invocation mode: it sleeps until a stimulus arrives. maxTicksPerWake: 32 is a safety net — in practice zero converges in two or three.

The resident model

export type ZeroState = {
    entries: AxonEntry[]
    seq: number
}

export const state: ZeroState = {
    entries: [],
    seq: -1,
}

export function sync(): void {
    for (const entry of kernel.store.session.get({ after: state.seq })) {
        state.entries.push(entry)
        state.seq = entry.time.seq
    }
}

The model is the log, folded into memory, and nothing else.

That's the decision the rest of the cognet rests on. Cold boot is sync() with the cursor at -1; a steady-state tick is sync() with the cursor at the high-water mark. Same call, different cursor — so there is no separate hydration path that could drift from the live one. See Memory.

The loop

sense

loop(async ({ signal, stop }) => {
    await phase("sync", async () => {
        sync()
    })

One fold, at the top of every tick.

On the first tick of a wake this pulls the triggering stimuli. On later ticks it pulls the cognet:action:typescript and cognet:action:result entries the kernel committed for last tick's run() — which is how tool results re-enter the model.

Note what the loop body signature doesn't use: stimuli. For a log-derived mind the log tail is the diff, so stimuli, the cognet's own outputs, and action results all arrive through this one door in one ordering.

render

    const messages = await phase("render", async () => {
        return air.render({
            base: await kernel.base(),
            scope: kernel.scope(),
            history: state.entries,
        })
    })

Context is assembled from three things: the agent's identity (base()), the executable tool surface (scope()), and zero's own resident history.

The kernel supplies the first two as raw material and has no opinion about where they land in the rendered context. That placement is zero's decision, and a different cognet would make it differently — or use a different format entirely. The AIR grammar zero uses is zero's choice, not the platform's.

invoke

    const { blocks, done } = await phase("invoke", async () => {
        const collected: string[] = []
        let stopped = false
        let speaking: string | null = null

        for await (const event of kernel.stream({ messages, signal })) {
            switch (event.type) {
                case "engine:text:delta":
                    if (!speaking) speaking = crypto.randomUUID()
                    await kernel.output("cognet:output:text", {
                        content: event.content,
                        chunk: { of: speaking },
                    })
                    break

                case "engine:text":
                    if (speaking) {
                        await kernel.output("cognet:output:text", {
                            content: "",
                            chunk: { of: speaking, final: true },
                        })
                        speaking = null
                    } else {
                        await kernel.output("cognet:output:text", { content: event.content })
                    }
                    break

                case "engine:typescript":
                    collected.push(event.content)
                    break

                case "engine:output:error":
                    await kernel.output("cognet:output:text", {
                        content: `[format] ${event.code}: ${event.message}`,
                    })
                    break

                case "engine:stop":
                    stopped = true
                    break
            }
        }

        return { blocks: collected, done: stopped }
    })

This is the part worth reading closely, because it's where the ABI's design shows.

The kernel yields raw engine events. It does not pre-label them as the cognet's output. When a text block arrives, zero decides to emit it. When a typescript block arrives, zero decides to collect it. That decision belongs to the cognet alone, and a cognet that wanted to critique its own code before running it would simply not push to collected here.

Streaming is chunked emission. Deltas emit as chunks of one correlation group; the closing engine:text closes it with final: true. There is no separate push channel — chunks are ordinary committed entries, so they reach every observer through the same pipeline as any other fact.

Code is collected, not run mid-stream. Blocks accumulate until the model has finished the whole message, so they run concurrently and an abort can't leave half a message acted on.

A format violation becomes an ordinary fact. When the model breaks the AIR contract, zero emits the error as text rather than throwing. Next tick's render shows the model its own violation and it self-corrects.

Stopping is causal, never inferred. engine:stop is the model's explicit <done/>. Zero never guesses that a response looked final.

act

    if (blocks.length > 0) {
        await phase("act", async () => {
            await kernel.run(blocks, { signal })
        })
    }

An array runs every block concurrently and returns results in order.

Zero doesn't inspect those results. It doesn't need to: kernel.run() commits cognet:action:typescript and cognet:action:result itself, so success and failure both flow back through the log identically and arrive in the model at next tick's sync(). That tick's render shows the model its own stdout.

yield

    if (done && blocks.length === 0) stop()
})

Four lines that carry the cognet's judgment about its own completion.

done alone isn't enough. If the model emitted code this tick, there will be another tick regardless of what it said — because the model must witness its own results before the wake can honestly end. A cognet that stopped on <done/> while a tool call was still unwitnessed would strand the result in the log, unseen.

Lifecycle

export default definePlugin(({ hooks }) => {
    hooks.on("boot", async () => {
        sync()

        const checkpoint = await kernel.store.get("checkpoint")
        kernel.emit("cognet:log:info", {
            value: checkpoint
                ? `zero: hydrated ${state.entries.length} entries (checkpoint seq ${checkpoint.seq}, live seq ${state.seq})`
                : `zero: cold boot, hydrated ${state.entries.length} entries`,
        })
    })

    hooks.on("shutdown", async () => {
        await kernel.store.set("checkpoint", { seq: state.seq, savedAt: Date.now() })
    })
})

boot calls the same sync() the loop calls. Rehydration isn't a special path.

shutdown persists a cursor and nothing else. Durable episodic writes already happened through the kernel at the moment they mattered, so the only thing in RAM worth saving is the high-water mark — and even that is an optimization. Delete the checkpoint and zero cold-boots with one full re-fold, losing nothing.

What zero doesn't do

Reading a reference implementation for its absences is often more useful than reading it for its features.

It holds no entities. The ECS world is constructed for every wake, and zero uses it purely for phase() timing. Its model is the folded log. That's a legitimate way to use the engine — the world is available, not mandatory.

It never inspects run results. Success and failure take the same path back into the model.

It has no retry logic, no summarization, no compression. Not because those are wrong, but because zero is the reference, and every one of them is a strategy decision that belongs to whoever needs it.

It ignores stimuli. The log tail is the diff.

Where to change it

The four phases are the natural seams:

PhaseChange it to
synchold a derived model — a summary, an index, a spatial map
renderuse a different grammar, a different context budget, a different format entirely
invokecall several engines, critique before running, route by block type
actgate execution, ask for confirmation, transform blocks first

None of that touches the kernel. The contract stays exactly seven verbs, whichever of these you rewrite.