Architecture teardown · ZCode 3.8.1 · GLM-5.3

The harness is mostly a queue, and the queue is why background agents win

I pulled apart the shipped ZCode binary and then checked every claim against 30 days of your own telemetry. This is what the runtime actually does with a turn, what it records about you, and the handful of numbers that should change how you drive it.

app 3.8.1.5310 window 30 days model requests tool calls sessions source evidence, not docs

Four things worth knowing. Your subagents outnumber you. Of sessions on this machine, are worker sessions and only are conversations you had.

You are paying for cache, not for thinking. of every input token billed in this window was a cache read. Output is of the total.

The model rarely talks. % of model responses ended in a tool call rather than a reply, which is why a turn here averages model round trips.

Search does not use the search tools. Glob and Grep were called zero times out of tool calls, because the runtime unregisters them and routes search through Bash.

At list price this month cost about . Almost all of it cached input at the cheapest rate on the sheet, absorbed by a subscription a fraction of that size.

Tokens moved
across model requests
Worker runs
p50 , longest
Biggest single turn
tokens, in tool calls over
Bash share
of all tool calls

01 One turn, from the inside

A turn is not a request. It is a loop that keeps a queue drained, and almost every interesting behavior in this app falls out of that shape.

The runtime is event sourced. Every step appends a typed event to a store, and the UI you look at is a projection rebuilt from those events. That is why a crashed session can be resumed, why a rewind is possible, and why a subagent can be resumed weeks later from its stored session. The list of event types reads like a map of the product: TurnStarted, ModelStreaming, ToolCallScheduled, PermissionRequested, CompactBoundary, MicrocompactBoundary, CheckpointCreated, RewindTriggered, SubagentSpawned, SubagentMessage, SubagentStopped, BackgroundTaskCompleted, TargetCompletionVerification.

1 · Input lands in a queue, not in the model enqueueRuntimeCommand

Your message, a background task notification and a subagent progress report all enter the same runtime command queue with a priority and a branch generation. If you rewound the conversation since a command was queued, the generation no longer matches and the command is dropped as stale rather than replayed into a history that no longer exists.

2 · Context is rebuilt, not appended createContextBuilderFromSnapshot

Each turn rebuilds the system context from a snapshot: environment, working directory, current date, memory index, skills metadata, and your AGENTS.md. The rebuilt block replaces the head of the message history in place. Change a file that feeds it and the next turn sees the new version without a restart, with one exception that bites people, agent profiles, which only load at session start.

3 · The model answers with tool calls finish_reason

Across requests in this window, ended in tool-calls and only ended in stop. Prose is the exception. A turn here averages model requests and tool calls, and the median turn runs .

4 · The scheduler groups the calls ToolScheduler, maxConcurrency 10

Tool calls are sorted into dependency groups, then parallel-safe calls run ten at a time. Anything not marked concurrent safe gets its own group and blocks the batch. This is the real ceiling on a fan-out: eleven agents in one message is not eleven in parallel, it is ten and then one.

5 · Every call passes a permission broker permission.riskLevel, sideEffectScope

Each tool declares a risk level and a side effect scope before it runs, and a subagent's requests are re-scoped through a broker that tags them with the child agent id. The record survives even when nothing is asked, which is how the scope histogram further down exists at all.

6 · Results are budgeted, truncated and written to disk resultBudget, artifactStore

Every tool has a maximum inline size and a truncation strategy. Large outputs are persisted as artifacts and referenced instead of pasted. Your largest single Bash result was , and only tool results out of hit truncation, so the budgets are rarely the thing that hurts you.

7 · The loop drains the queue again drainPendingRuntimeCommandsForActiveLoop

Before the next model call, the runtime drains anything that arrived while the model was thinking. Several notifications collapse into one synthetic message, up to three origins named at once. That merge is why six background workers finishing together does not cost you six wake ups.

02 Three kinds of agent, and how you actually call them

There is one main agent, two built in workers, and a custom worker slot you have never used. They behave differently enough that the choice matters, and your own record shows which one you reach for.

Every Agent call in the window
Of the general-purpose calls, did not name a type at all. An omitted subagent_type silently means general-purpose, which is the most expensive default in the list.

What each one costs per request

Per model request, by actormodel_usage, grouped by agent name
ActorRequestsAvg tokensAvg outputAvg durationTools per reqErrors

Explore is the outlier in the right direction. It carries a third of general-purpose's context per request and makes twice as many tool calls per request, which is exactly the profile you want from a searcher: cheap context, high throughput, no writing. It is also the only one that does not get this file injected into it.

The main agent is the expensive one per request, tokens against general-purpose's , because it carries the conversation. That is the entire argument for delegation in one number: every tool result you keep in your own context gets re-read on every subsequent request of that conversation, forever.

How long a mission you write for each

Spawn prompt length in charactersfrom the stored tool inputs
Longest single mission: characters. That is a 33 KB brief handed to an agent with no memory of the conversation that produced it.

The tools each kind reaches for

You, in conversationinteractive sessions
Workerssubagent sessions

Nearly the same shape, with one telling difference. Agent appears in your list and never in theirs, because workers cannot spawn. Everything else is the same job done in a different context, which is the strongest evidence that delegation here is about context accounting rather than capability.

?
The fourth kind does not exist yet on this machine. Custom profiles live in ~/.zcode/agents/*.md and yours is empty. Every worker you have ever run was one of the two built ins, on default settings, with the default model.

03 The steering channel nobody documents

The docs describe subagents as fire and forget. The binary says otherwise. There is a full duplex channel between a coordinator and a running worker, and none of it appears in the documentation.

Downward · SendMessage

A message to a running worker is handed to steerTurn with delivery mode guide. If the worker has no active turn the runtime retries twenty times at ten millisecond intervals before giving up and queueing it for the next tool round. The worker sees your text prefixed with Message from coordinator: and your summary line.

Send to a worker that already finished and something better happens. The runtime reopens its stored session, replays it, and starts it again in the background with your message as the new prompt. The delivery status comes back as resumed_background. That is a real conversation with an agent that ended twenty minutes ago.

Upward · RespondToCoordinator

Workers get a tool that the parent never sees in its own list. It queues a message into the parent runtime as a model-only synthetic user message, so progress arrives mid-run without ending the worker's turn. The tool's own instructions tell the worker to keep going afterwards unless the coordinator explicitly changed the task.

!
Foreground workers cannot use any of this. Not because the channel is disabled, but because a blocked coordinator has no turn in which to call a tool. The capability exists in one mode only, and that is the entire argument for making background the default.

What it cost you not to know

Of spawns recorded here, asked for background, which is % of them, and of those happened in the last three days. Of the that failed, died with a cancelled parent turn and hit the ten minute idle watchdog. Both failure modes are foreground only. Background workers detach from the parent abort signal and carry no watchdog at all.

Worker run outcomes runs, from agent metadata on disk

04 Yes, a session can invoke another session

You noticed correctly. It is not a metaphor in this runtime, it is a foreign key.

The session table has a parent_id, and of your sessions point at another session. Every subagent is a real child session with its own message history, its own token accounting and its own row. The child id is derived from the agent id, sess_subagent_agent_<uuid>, so the link survives restarts and is exactly how a finished worker gets resumed weeks later.

The session graphone dot per session, grouped by parent
sessions became parents. The busiest one spawned children.

Six kinds of session, three of which you have used

The runtime recognises interactive, fork, selection_side_chat, workflow_parent, workflow_child, subagent_child and nested_workflow_child. Your database holds interactive, subagent children and side chats, which are the little conversations that open when you select text and ask about it.

nested_workflow_child is the interesting name in that list. Workflows can contain workflows, and the session_task_link table that records those trees carries depth and path columns, so nesting is designed for rather than accidental. On this machine that table has rows and workflow_run has . An entire orchestration subsystem shipped, and you have never touched it.

!
There is also a tool for reading another session's history. ReadSessionContext takes a sess_* id, a question, and a strategy of either relevant or handoff, then summarises that other conversation into this one. Cross session memory transfer, shipped, zero uses in your database.

05 Most sessions on this machine are not yours

A subagent is not a lightweight call. It is a full child session with its own id, its own event stream, its own transcript on disk and its own row in every usage table.

Child sessions are named sess_subagent_agent_<uuid> and live beside yours in the same database. They carry a cut down config: subagents.enabled is false so they cannot spawn, plan mode is removed from their tool list, and their toolset is either the full main set or the narrow explore set. Everything else, model, thinking level, skills, MCP servers, permission mode, comes from the profile.

Sessions by kinddatabase, session table
Model requests by actordatabase, model_usage
How long a worker runs runs, log buckets
p50 . p90 . The tail is where foreground hurts: runs went past twenty minutes with your turn blocked behind them.

Explore is the cheap one and the numbers show it. It answered model requests for tokens, while general-purpose answered requests for . Roughly a fifth of the requests, a fourteenth of the tokens. Explore also skips AGENTS.md injection entirely, which is both why it boots lighter and why your orchestrator protocol never reaches it.

06 The cache is the whole economy

A million token window changes what a token is. Almost nothing you pay for is new text.

Where tokens wentpercentages are shares of all tokens, input plus output. Hover for exact counts.

Cache reads are of input, and 96.8% of all tokens including output, which is the number the bar above splits. Fresh input is the thin sliver. Output, the only part that is actually written by a model, is of the total. When a run costs more than you expected, the cause is almost never verbosity. It is how many times a large context was re-read, which is a function of how many tool round trips the turn took.

Daily tokensbar height is total, the darker share is subagent traffic
The lever is round trips, not brevity. Median turn: tool calls. p90: . The p90 turn re-reads its context four times as often as the median one, and the context is the same size either way.

07 What this month would have cost at list price

Published Z.AI rates: $1.40 per million input tokens, $0.26 per million cached input, $4.40 per million output. One oddity to name before the table: cache_creation_input_tokens is zero on all of your requests, while cached reads run to billions. The cache is obviously being written. Writes are simply not metered or reported while cache storage is free, so the column stays at zero and only the reads show up.

Metered at API pricesyour exact token counts, their published rates
ModelFresh inputCached inputOutputCost

Against a GLM Coding Plan at $144 a month for the Max tier, that is roughly times the subscription price in metered tokens, in one month, from one machine. At the $64.80 Pro tier it is times.

Where the money would have gonehover for exact dollars

The shape is worth staring at. Cached input is % of the bill despite being the cheapest rate on the sheet, because volume beats unit price by two orders of magnitude. Output, the part everyone worries about, is %.

Individual runs get expensive fast. Your largest single turn moved tokens, which at cached rates is about of metered tokens for one turn. The subscription absorbs it, which is the actual product here: the plan is not cheaper compute, it is removing the meter from your attention.

08 Compaction, including the half you have never seen

You said you never use /compact. There are two compaction systems in here, and the one you have never heard of has probably been running behind you for a month.

Microcompaction, the quiet one

This one never calls a model. It walks your message history, finds tool results, and replaces the old ones with a placeholder while leaving the messages and the tool call structure intact. Your conversation keeps its shape and loses its bulk.

It only touches results from a fixed list: Read, Bash, Grep, Glob, WebFetch, WebSearch, Edit, Write, ApplyPatch. It keeps the most recent group of results untouched, skips anything containing an image or a file, and if the whole exercise would not save a minimum number of tokens it throws the work away and leaves your history alone.

!
It also fires when you walk away. There are two triggers: token pressure, and idle time since the last assistant reply. Leave the app sitting long enough and it prunes old tool output on its own. There is no command for this, no setting in the UI, and no row anywhere in your telemetry, because it costs no model call. The only trace is a MicrocompactBoundary event in the session stream.

The default ceiling it aims for is the smaller of 90% of the context window, or the window minus 2,000 tokens. It is a garbage collector, and like a good one it is invisible.

Full compaction, the one with the command

/compact [instructions] runs the real thing, and the optional instructions are worth knowing about because they let you steer what the summary keeps.

The runtime asks a model to rewrite the conversation into nine fixed sections: primary request and intent, key technical concepts, files and code sections, errors and fixes, problem solving, all user messages, pending tasks, current work, and an optional next step. The prompt is explicit that security relevant instructions and constraints must be preserved verbatim so they still apply afterwards, which is a quiet admission that compaction loses things and that some things must not be lost.

It triggers on four reasons: you asked, the context hit its limit, you downshifted to a smaller model, or the provider overflowed. The automatic threshold is 95% of the effective window after an output reserve is carved out. Two circuit breakers sit behind it. After three consecutive failures it stops trying, and a rapid refill breaker blocks a second compaction when the context refills immediately after the first, so a runaway loop cannot eat your session.

There is even a dedicated model role for it. The runtime distinguishes main, compact, lite, review and subagent roles, so the summariser can be a cheaper model than the one doing your work.

Every compaction in the windowbar is the context read, the tip is the summary written

Why it barely happens to you

Eight compactions in thirty days, across sessions. One session compacted three times in a single afternoon. Two of the eight were inside a subagent, which is worth pausing on: a worker you spawned filled its own context and summarised itself without telling you, and the only evidence is a row in a usage table.

The rest of the time you never come close. A million token window with an auto threshold at 95% means the trigger sits near 950,000 tokens, and context_exceeded fired times across turns. Your compactions clustered at 54K to 283K of context, which means those sessions were running against a much smaller effective window than the one you have now.

The reason to reach for it anyway. Compaction is not only about running out of room. Every token still in your history is re-read on every request of that conversation, and re-reads are % of what this month would have cost. A long session that has stopped needing its early tool output is paying rent on it. /compact with instructions is the manual version of that decision. Delegating to a worker is the automatic one.

09 Why Bash is half of everything

Glob: zero calls. Grep: zero calls. Bash: . This is not a habit, it is a runtime decision.

ZCode ships three search binaries inside the app bundle, bfs, ripgrep and ugrep. When embedded search is enabled, and it is enabled on this machine, the runtime unregisters the Glob and Grep tools completely and the model searches through Bash instead. The Explore agent's tool list changes shape for the same reason, from seven tools down to five.

Tool callsby number of calls
Click a row for its error count and worst case duration.

The consequence is worth sitting with. Half your tool traffic goes through the one tool with the widest blast radius and the least structure, and the runtime put it there. Every safety rail you would get from a purpose built search tool is replaced by whatever the model typed into a shell.

10 The permission system, and the fact that it is off

There is a careful permission model in here. Risk levels, side effect scopes, deny priorities, per-tool approval sources, a broker that re-scopes child agent requests. On this machine it approved calls and asked about one.

Side effect scope of every tool callrecorded even when nothing is asked
Session mode on model requests
yolo · build · plan

I am not going to moralise about yolo mode. You run long autonomous jobs and approval prompts would make that impossible. But the record is worth naming honestly: calls with system scope and with network scope ran without a single gate, and the one denial in the entire database is the only evidence the gate works at all.

The one guard that stays on regardless is quieter and better designed. A project scoped agent profile has its permissionMode stripped at parse time, so a profile checked into a repository cannot grant itself bypassPermissions on your machine. Only user scoped profiles can set it.

11 GLM-5.3 against 5.2, measured here

5.3 arrived recently in this data, requests against . Small sample, but the shape of the difference is already visible.

Per request averagesmodel_usage, this machine only
ModelRequestsTotal tokensOutput per reqTime to first tokenDurationTool calls per req

5.3 writes times more output per request and makes fewer tool calls per request. Read that together and it is a model that does more per round trip, which is exactly what you want when every round trip re-reads a million token context. It is also slower to first token, seconds against , so it feels heavier in the chair even when it costs fewer trips.

The thinking level rides along as a request variant. In this window: max on requests, high on , low on , nothink on . Titles and other housekeeping calls run at the cheap end, which is why session title generations cost tokens each on average.

12 The shape of your turns

Every completed turn in the window, tool calls against tokens. Both axes are logarithmic because the spread is four orders of magnitude.

completed turnshover a point for its numbers
The cloud at the bottom left is ordinary work. The arm to the upper right is long autonomous runs.

The extreme is instructive. One turn burned tokens across tool calls and model requests over , and it was a subagent. The longest turn overall ran . Nothing in the runtime stops this. There is no turn budget, no token ceiling, and context_exceeded fired times in the whole window, so the million token window never once ran out.

13 Quirks, oddities and one genuine what the hell

Things I did not expect to find, in rough order of how much they made me stop and reread the code.

It ships its competitors

Inside ~/.zcode/bundled-agents sit 77 MB of tarballs: Claude Code, Gemini CLI and OpenCode, bundled as alternative engines. ZCode also parses .claude-plugin/plugin.json and .codex-plugin/plugin.json, and exposes a hidden Task tool that is just an alias for Agent so Claude Code plugins keep working.

The default mode is called yolo

Not a nickname in a changelog. The literal string in the config schema, alongside plan, build, edit and auto. of your model requests ran in it.

The harness warns the model about its own files

The TaskOutput tool description tells the model, in prose, not to read a worker's .output file because it is a symlink to a JSONL transcript that will overflow its context window. A shipped tool whose documentation is a warning about the tool.

A thinking level named nothink

Requests carry a variant: max, high, low, nothink. Yours split max, high, low, nothink. Session titles are generated with thinking disabled, which is the correct engineering call and still funny to see in a column.

Steering waits exactly 200 milliseconds

A message to a worker calls steerTurn, and if the worker has no active turn the runtime retries twenty times at ten millisecond intervals before giving up and queueing the message instead. Twenty tries, then patience runs out.

Read only Explore has a shell

The docs call Explore read only. Its tool list contains Bash. What actually stops it writing is a permission service constructed with empty allow and deny sets and auto approval off, so a write attempt has nobody to approve it. The safety is real, the description is not literal.

Your background flag left no trace

spawns asked for background. Only records still show it, because when a background child finishes the runtime overwrites the tool result with the completed payload. The database cannot tell you afterwards which mode a finished spawn ran in.

One shell call returned 53 MB

A single Bash result of . Across every tool call ever made here, only were truncated, so the budget almost never saves you. The model is trusted with a firehose.

The reply channel is used as a second goodbye

Workers called RespondToCoordinator times, and every message was a completion summary: "research complete", "exploration complete". A channel built for mid-run progress, used exclusively to say goodbye twice.

One denial in the entire history

tool calls approved automatically. One asked. One denied. That single denial is the only proof in stored message parts that the permission system can say no.

The million token window never filled

context_exceeded fired times across turns, including a turn that ran eight hours and one that burned 122.7M tokens. Compaction ran times all month.

The agent asks you almost nothing

AskUserQuestion fired times in turns. Once every 33 turns, it wondered what you wanted.

Traps that will actually cost you time

Profiles do not hot reload

Edit an agent definition and the running session keeps the old one. Only a new session picks it up. The one exception is switching the primary model, which profiles without an explicit model follow immediately.

A custom tools list silently kills MCP

Naming any tools in a profile makes the list exhaustive. Every MCP tool disappears unless you write each one out as mcp__server__tool. Wildcards are ignored without an error.

MCP servers freeze at session start

A worker only sees servers connected when the primary session started. Connect one mid session and every worker you spawn afterwards is blind to it.

Idle time tasks refuse background agents

The free capacity queue rejects them outright with Idle-time tasks do not support background agents. It is the one place foreground is correct.

maxTurns is documented but unenforced

The key parses, defaults to 4 and reaches the child runtime. No enforcement path exists anywhere in the 3.8.1 bundle. Do not plan around it.

Resuming a worker can expire

Of your SendMessage calls, one came back with No active local_agent task found. Resumption needs the in memory registry from the same session. After a restart the id is just a string.

A worker's background shells die with it

Background Bash inside a subagent is cancelled during cleanup and its completion notifications are sealed. Anything a worker needs to see must run in its own foreground.

The agents directory is scanned recursively

~/.zcode/agents/ also holds those bundled CLI installs. 457 markdown files under it get read at startup looking for frontmatter. None parse, so nothing breaks, but keep your profiles as flat files.

14 Where things live

Where is the evidence for all of this?
Two places. The shipped runtime at /Applications/ZCode.app/Contents/Resources/glm/zcode.cjs, 12.4 MB of bundled JavaScript with readable identifiers, and your own telemetry at ~/.zcode/cli/db/db.sqlite, currently 777 MB across 19 tables.
How do I query my own usage?
Open it read only so you never touch the live file, then treat it as a normal database.
// safe: read only, WAL untouched
sqlite3 'file:~/.zcode/cli/db/db.sqlite?mode=ro' \
  "select tool_name, count(*) n, sum(status='error') err
    from tool_usage group by 1 order by n desc limit 20"
Useful tables: model_usage per request, turn_usage per turn, tool_usage per call, session with a task_type that separates your conversations from worker sessions.
Where does a worker write?
~/.zcode/cli/agents/<parent session>/<agent id>/ holds four files per run: metadata.json with the full prompt and usage, output.txt with the final report, task.output, and transcript.jsonl with everything.
What is the Task tool?
A hidden alias for Agent, kept so Claude Code plugins that ask for Task keep working. ZCode also reads .claude-plugin/plugin.json and .codex-plugin/plugin.json next to its own format, and bundles the Claude Code, Gemini and OpenCode CLIs as alternative engines.
What does the app actually keep from a run?
More than you would expect. Every prompt you sent to a worker, its full transcript, per request token counts including cache splits, per tool durations and byte counts, todos, and the trace and span ids that tie them together. It is a genuinely good dataset, and it is all local.