Skip to main content

Agent

A named, durable worker owned by one child project. It holds a task board, an event log and a transcript, and it hibernates between wakes.
🔴 Five of those six are a mirror that nothing writes back to. Read the board and the log instead.status, blocked_reason, attention, next_wake_at and wakes are columns on the agent’s Postgres row, and no shipped code path updates them. The runtime holds those facts inside its own durable object and reports exactly two things back to naïve — agent.budget_reached and agent.job_complete — neither of which lands in that row. So every agent reads status: "idle", blocked_reason: null, attention: false, next_wake_at: null, wakes: 0 whatever it is doing, and list({ status }) filters against those constants.The board and the log are served straight out of the runtime and are live:
harness_sha256 is stamped only when the runtime records a deliverable carrying one, so an agent that has run and delivered nothing still reads null.
status is what it is doing; attention is whether it needs you. They are separate fields because an agent can be idle and still be waiting on you. The five values the column is permitted to hold, subject to the warning above about which of them you can observe today:

Task — and why there is no separate “job”

Every unit of work is a task, whatever door it came through. The board is the queue, so there is one noun and one table.
source is platform-set and no request body can change it. A caller-settable source would make the observability column a lie the first time someone passed "schedule" from an API call.

Job vs message: one route, two ways to read it

They are the same call. What differs is whether you want a stream back.
Over HTTP it is literally one route — POST /:id/tasks — and the Accept header decides. Accept: text/event-stream streams; anything else returns 202 and the task record.
202 means durably queued. The row is inserted and the alarm armed in one transaction before the call returns, so a runtime outage is a 503 you can see and never a silent delay.

Parked tasks, and how to un-park them

An agent can stop and ask you something. The task goes to waiting with blocked_reason: "needs_input", and it stays there until you answer.
Replying to a task that is not parked answers 409 task_not_replyable.
Proof status. The park-and-resume round trip — the agent calling message_user, the task landing at waiting, replyTask un-parking it and the run continuing — is exercised in-process against the real router and the real core. It is not yet part of what has been run against a deployed runtime, so treat it as working-and-unproven-in-the-large rather than as a load-bearing production path.

Cancelling is cooperative

It never aborts mid-tool. A task holding a ten-minute bash takes ten minutes to stop, and bills them. cancel_requested_at is what lets you render “cancelling — waiting on bash (4m12s)” instead of appearing hung.

Idempotency

A business key, and permanent. A second send with the same key returns the same task and does not run again — forever, not for 24 hours. That is a different thing from the transport-level Idempotency-Key header, and both are real.

Completion window

One pinned model, three schedules. It selects how a turn is executed, never which model runs. Fan-out is a latency knob and the batch hint a cost knob, moved in opposite directions on purpose. On GLM-5.2 the window also selects the vendor’s own price tier, which is the larger of the two effects. Measured over 142 graded trials on terminal-bench 2 (long-horizon-8, GLM-5.2-FP8, n=2), as ratios rather than dollars: flex costs 0.70 per solved task against asap’s 1.00, standard 0.94. Six of eight instances scored identically under all three windows, and the two that moved (compile-compcert, configure-git-webserver) failed their tests rather than running out of time. Wall clock moves with the window and is published alongside: asap 24.7 min per trial and 43.8 s per model call, standard 50.8 min and 58.2 s/call, flex 45.1 min and 79.5 s/call. The standard and flex arms ran at 2.5x and 3x the agent wall-clock cap the asap arm ran at — deliberately, because a slower window against a fixed cap fits fewer steps and fails for a reason unrelated to price. That makes the cost column a fair comparison and the solve column one at unequal time budgets; only the asap row is cap-matched.
🔴 Ratios rather than dollars, and the ratios above are the kind that are exact. The rate card books every prompt token at the full input rate, while the actual bill charges tokens served from cache at a fraction of it — so the card over-states, and how much it over-states depends on which API dialect the harness speaks and therefore on whether a cached-token count comes back at all.That makes the over-statement a property of the arm. The three window figures above are one arm compared against itself, so it appears identically on both sides of the ratio and cancels exactly: they need no correction and carry no caveat. A ratio between two different arms is a different matter, and is not published until each has been reconciled — measured, two harnesses are 3.4x apart. See Pricing.
A harness comparison against a fixed cap measures the cap too — and it measures the instance list before that. Scored on the seven instances all five harnesses ran (n=14 each), raw solves put Vetta’s harness first at 12/14 and claude-code second at 11/14, but conditional on not hitting the wall-clock cap claude-code is 100% (10/10) and first; 4 of its 14 ran out of clock. The cap is part of the mechanism and not all of it: the arms differ by 1.8x in seconds per model call (15.7 on hermes to 28.7 on bare pi), and hermes is the fastest per step of the five while still solving fewer than claude-code. Both readings, the clean uncapped 3-instance subset that leads the comparison, why the eighth instance is not in the denominator, and the reason cursor cannot be measured in this cell are on Pricing.
Set it per agent:
The window does not change the model, and it does not change the answer. Waves never reorder calls that could observe each other, so the transcript a fan-out plan produces is identical, entry for entry, to what sequential execution produces. It buys wall-clock, not accuracy — in either direction.It does route, and only on one model. standard and flex are served on zai-org/GLM-5.2-FP8 and nowhere else; asap is the default and is admitted on every model. A non-default window on any other model is meant to be refused with window_unavailable, never downgraded — a window served as asap under another name reports a discount nobody bought. Every window produces a real token stream, so sendMessage({ window: "flex" }) streams normally.
🔴 On our deployed staging host that refusal never fired. An agent pinned to anthropic/claude-sonnet-5 with completion_window: "flex" ran its task to status: "done", was billed 8,054 micro-USD, and its task row recorded window: "asap" — the silent downgrade the gate exists to prevent, on every route. Two sub-facts came with it: an agent’s completion_window was not inherited by its tasks at all, and the only model the gate admits is not servable on that host, so window_unavailable was unreachable by any route we could find.Treat the refusal as the shipped intent and verify it on your own deployment before relying on it. If a non-asap window matters to your bill, read back the window recorded on the task rather than trusting the request.
🔴 Three places take a window. They are not the same knob.
  • The agentcompletion_window is the one the executor reads. It is what moves the fan-out, the sub-agent width and the batch hint, because a run declares one strategy in its manifest before turn 1.
  • A taskwindow on sendJob/sendMessage takes all three values and selects the vendor tier the call is priced at. It does not change the run’s schedule. Omitting it means "asap", not the agent’s window. 🔴 Measured on deployed staging, a per-task "standard" is still replaced by the agent’s own completion_windowasap and flex store as requested, standard does not. The fix is in the runtime source; the deployed edge worker is behind it. Read the window back off the task row rather than assuming.
  • A schedule entrywindow is validated at the API, validated again when the config reaches the runtime, and then dropped: the runtime’s schedule table has no window column and every schedule-fired task runs asap.
See Pricing for what this means on a bill.

Budget

Required at create. An agent with no cap is not creatable, because a null cap means no cap and no per-agent spend record at all.
Micro-USD, not cents. A turn’s reserve is routinely sub-cent, and rounding at this boundary would make the API’s cap and the runtime’s cap two different numbers. period: "total" is the long-horizon statement — “spend $50 on this job, ever” — and it is the one period a project-level cap has no way to express.
🔴 alert_at and hard are accepted, stored, echoed — and change nothing.alert_at is validated as a 0–1 fraction and reaches the runtime’s config, and no code compares spend against it. There is no budget.alert emitter, and the platform’s webhook catalogue deliberately advertises no budget-warning event for that reason. Use the budget event on the stream instead: it carries spend against both ceilings on every turn, before the call, which is more than a threshold notification would give you.hard: false does not park the task for an approval. Both the period cap and the per-task ceiling refuse unconditionally; the flag is not read at the gate.

How the cap actually binds

Before each model call the runtime prices a reserve for that call against observed rates and checks spent + reserve > cap. If it would cross, the task stops at braked with blocked_reason: "budget". It does not half-run a turn it cannot pay for.
🔴 Measured on deployed staging, this binds as an ADMISSION gate and the first task overshoots. With cap_micro_usd: 1, period: "total", hard: true: task #1 ran to done and spent 22,534 micro-USD against the 1 micro-USD cap; task #2 came back braked, blocked_reason: "budget", spent: 0. The park path is exactly as specified — from the second task onward. A fresh agent, or one whose period just rolled over, gets one unbounded task. Size the cap accordingly.
🔴 braked is terminal in practice. The board only ever claims tasks in queued, running or waiting, and nothing moves a task out of braked — neither the period rolling over nor a higher cap. Raise the cap so the next task can run, and re-send the work:
budget on update is a full replacement, not a merge — a partial budget where period moved and cap_micro_usd did not is a cap nobody meant to set.

How the per-task ceiling binds

max_task_micro_usd is checked at the same moment and against the same reserve, but over that task’s own spend — its root run plus every sub-agent it delegated to, so a task cannot delegate its way around its ceiling. The two ceilings mark a task differently, and the difference is deliberate. A period cap resets, so a task that crosses it is parked at braked. A per-task ceiling never resets, so a task that crosses it ends: status: "failed", non-retryable, with the spend, the reserve and the ceiling named in error. When one call would cross both, the cap wins, because parking is the recoverable answer of the two — though see above for what braked costs you today.
🔴 Measured on deployed staging, the per-task ceiling did not fire at all. An agent with max_task_micro_usd: 1 and a period cap large enough not to interfere ran three consecutive tasks to status: "done", spending 17,954 / 10,359 / 12,054 micro-USD — up to 18,000x the ceiling — with error: null and blocked_reason: null on each. Reproduced on a second agent (21,464 and 22,871 micro-USD). The paragraph above is the shipped intent; it is not what that host does. The period cap does brake, so the two ceilings read different sources. Bound a deployment on cap_micro_usd and the account balance, and verify the per-task ceiling yourself before relying on it.
Both numbers are on the event stream every turn: budget carries spentMicroUsd/capMicroUsd and taskMicroUsd/maxTaskMicroUsd.

Reading spend

Every paid component prints, including the zerosinference, sandbox, web_search, browser, storage, wakes. Money is decimal strings. Nothing in the client does arithmetic on it. Two totals, and they are not the same number: Every group carries source: "meter" or source: "ledger", saying which record it came from. Never add the two totals: they are two records of one agent’s money, not two pots of it.
task.spent_micro_usd is the runtime’s own synchronous counter, which the pre-call gate needs — and spend() reads that same meter, so the two now agree instead of describing different worlds.wakes always reports rate_configured: false today: the axis exists and has no meter. It prints as a zero with the reason rather than being omitted.
🔴 spend() used to answer zero for agents with real spend, and why it did is worth knowing. One agent whose own task board summed to 41,125 micro-USD across 16 tasks reported spent_credits: "0.0000" with calls: 0 on every component; two others (44,335 and 21,277 micro-USD) did the same. The route read the credit ledger and nothing else — and an agent’s model calls never write a ledger row, because its runtime calls the model vendor directly. So inference is now read from the runtime’s meter, which is why billed_credits can be 0.0000 while spent_credits is not: that money is metered and reported, and is not yet on an invoice.A deployment with no agents runtime reports metered.available: false and the ledger’s side alone. One that HAS a runtime and cannot reach it failsagent_runtime_unavailable — rather than answering zero, because a zero was exactly the lie this fixed.

Schedule

A cron expression, a timezone, and the work to send when it fires.
  • Five fieldsmin hour dom mon dow. Seconds are not a field, and a 6-field expression is refused.
  • text is required. A schedule with no work is a bug that fires forever.
  • enabled defaults to true.
  • window is accepted and dropped — every schedule-fired task runs asap.
  • A fire that lands while the agent is busy is collapsed, and the collapsed count is written into the task’s own text (“This schedule fired N times while you were unavailable… You are late.”). The missed_fires field on a task is hard-coded to 0: collapses are counted against the schedule, not the task.
See the scheduled agent guide.

Tools

Which naïve primitives the agent may reach. Every flag narrows and none widens.
Reads come back as a requested / effective pair:
A flag cannot widen the Account Kit. web_search: true resolves to naïve’s search primitive under the same child project’s kit, so a kit with search turned off removes the tool whatever the flag says. Read effective, never requested, when a run does not use a tool you granted.

Delegation

delegate_models is the only switch. An empty array — the default — means delegation is off and the delegate tool is absent from the schema list entirely.
Sub-agents buy context isolation, not wall-clock: on this core the board takes one run per turn, so two live children run one after the other in the same slice.

Sandbox

An agent with sandbox set gets a Linux micro-VM it can write files in, run commands in and test against.
  • "none" — no sandbox. The default.
  • "auto" — the agent gets one provisioned on demand.
  • "<workspace id>" — pin it to a specific workspace so state survives between tasks.
See the sandbox agent guide.

Limits — the bounded wake

Eleven numbers, and they are materialised onto the agent at create. get() shows the values it will actually run with, never a pointer to a default that could move under it between two wakes six weeks apart.
🔴 sliceWallMs bounds a WAKE, never a task. 840,000 ms is 14 minutes — a hard guard under the platform’s 15-minute alarm ceiling. A slice budget above it is not a longer run, it is a killed one. A task that needs six hours spans as many slices as it needs.Two cross-checks are enforced: turnWallMs may not exceed sliceWallMs (a turn that cannot finish inside its slice can never finish at all), and toolTimeoutMs may not exceed half of sliceWallMs (a timeout longer than half a wake cannot be enforced).
An unknown key is an error, never an ignore. A typo’d slice_wall_ms that was silently dropped is an agent running on defaults while you believe otherwise. On update, limits and tools merge field by field; everything else replaces.

Event log

One append-only log per agent. It is written whether or not anyone is watching.
That list is what the SDK’s AgentEventKind union names, and the log is wider. Nothing filters the read path, so a stream can also carry batch_planned, parallel_dispatch, speculation and cost_disagreement. Treat kind as open and keep a default branch.
Read it as a page, or attach to it live — one route, two representations:
after is a POSITION, never a time. Replay is strictly greater than the value you pass, so a resume loses nothing and duplicates nothing.Retention is 20,000 rows, no TTL. If your offset has aged out you get truncated: true and earliest_seq, and the replay starts there.
Watching does not pin the agent awake, so tailing a run does not change what it costs.

Deliverable

The output of work, with a manifest.
  • kindreport | doc | code | dataset | image | video | pr | deploy | other
  • Up to 20 per task, 25 MB each.
  • download_url is minted fresh on every read, expires in an hour, and is unauthenticated once issued — never cache it, call again instead.
  • download_url is null when the deployment has no storage sink configured, and then content carries the whole artifact. That is a deployment state, not an error.
🔴 A final deliverable must carry an artifact or say why it does not. The server refuses final: true with no text, no storage_key and no no_artifact_reason. Without that refusal, “deliver” degrades into a status update.
Proof status. The full fetch-back loop — the agent’s own deliver writing an artifact, and deliverables()/deliverable() reading it back with a fresh download_url — is exercised in-process against the real router and the real core. It is not yet part of what has been run against a deployed runtime. The manifest write and read routes themselves are; the artifact round trip through a storage sink is the part that has not been.

Errors

Every refusal arrives as naïve’s envelope with a stable code.
window_unavailable is sent: standard or flex on any model other than zai-org/GLM-5.2-FP8 is refused with it rather than downgraded, at the first model call rather than at create. Handle it if you set a non-default window. window_cannot_stream appears in the published type and is never sent — every window streams.