Skip to main content

Overview

Requires Authorization: Bearer nv_sk_…. Gated by the agents primitive in the child project’s Account Kit, and the gate is strict: an absent kit entry denies, not just an explicit enabled: false. A denial answers 403 forbidden with details.reason = "primitive_disabled_by_kit". Routes are mounted twice:
🔴 POST /v1/agents refuses without a resolved child project. Creating an agent requires one — it is the credential boundary that owns the vault entries and the Account Kit. Address /v1/users/:user_id/agents, or select a subject with the X-Naive-User-Id header.
Tenancy is 404, never 403. Another tenant’s agent id and a nonexistent one produce the same answer everywhere in this primitive, so an id is never confirmed to someone who should not have it.
One child project owns many agents, and the child project — not the agent — is the boundary. A key scoped to it reads both siblings’ transcripts and can delete either. Separate agents that must not see each other into separate child projects.

Endpoints

Create

🔴 The last four are what every agent reads, forever. status, blocked_reason, attention, next_wake_at and wakes are columns on the agent row that no code path writes: the runtime holds those facts in its own durable object and posts back only agent.budget_reached and agent.job_complete, neither of which lands in the row. A running, braked or scheduled agent still reads "status": "idle", "next_wake_at": null, "wakes": 0 — and GET /v1/agents?status=running returns nothing.Use GET /:id/tasks and GET /:id/events for live state. harness_sha256 is stamped only when the runtime records a deliverable carrying one.

Every field of the create body

Twelve top-level fields on POST, plus paused on PATCH — and this table is the whole of both. Every one is persisted, and every one except metadata is carried to the runtime on the config push that follows the write.
🔴 Three things on this page are accepted, stored, echoed back, and change nothing the runtime does. They are marked in place below rather than quietly listed as supported: budget.alert_at, budget.hard, and a schedule entry’s window.
budget: tools — the six keys are web_search, browser, sandbox, storage, delegation and connections, and any other key is refused, not dropped. Five of them take true or false and nothing else; sandbox takes the mode string "none" or "auto". 🔴 A toggle this deployment cannot serve reads back effective: false with an unavailable_reason, and the agent is not offered the tool. web_search, browser and connections have no substrate in any binding and always answer unavailable_reason: "no_substrate"; sandbox answers not_configured / credential_rejected / credential_foreign; storage answers not_configured when no object store is set, which is also when download_url is null on every deliverable. requested is never rewritten — your intent is preserved and the answer is separate. GET /v1/agents/status carries the same table, per toggle, with a sentence and an operator hint, before any agent exists. 🔴 A pinned workspace id is refused. tools.sandbox was documented as "none" | "auto" | "<workspace id>" and the third form was accepted with a 201 and read back as effective: "<that string>" — while reaching no component that provisions a sandbox. AgentConfig has no workspace field and the runtime’s workspace binding never sees the value, so the pin was a setting nothing honoured. It is now a 400 naming the field, rather than a 201 confirming a pin that does not hold. 🔴 The other five stopped accepting strings. They were typed boolean | string so that sandbox could carry a mode, and a string on any of them was read as on unless it was "" or "none" — so {"browser":"false"} was a 201 that granted the browser. They take booleans. schedule entry: limits — the eleven keys, all integers of milliseconds or counts: sliceWallMs, turnWallMs, maxTurnsPerSlice, maxToolCallsPerTurn, toolTimeoutMs, maxTurnsPerTask, taskWallMs, maxSubrequestsPerSlice, maxDepth, deferredMaxMs, maxPollAttempts.
Both POST and PATCH are strict. A key the schema does not know is 400 invalid_input, naming the field, the allowed set, and — where the mistake is a known one — where the field you meant actually lives. Send only the fields in the table above.On PATCH, limits and tools merge field by field; budget, schedule, secrets, connections, delegate_models and metadata replace wholesale. A partial budget where period moved and cap_micro_usd did not is a cap nobody meant to set, so the whole object is required together.budget round-trips. GET emits budget.period_start and the platform owns it, so on PATCH you may send it back unchanged and it is ignored — read a budget, change one number, write the whole object back, 200. A different period_start is still 400: accepting one and discarding it would report a billing period that never moved. At create there is no period to echo yet, so period_start there is refused outright. budget.alert_at accepts the null GET emits for an agent that has never set a threshold.

Field rules

vendor/model, and never an alias. auto, best, latest, default and preview are refused with invalid_input. An alias resolves differently on two days, which makes two runs of the same agent incomparable.There is deliberately no per-model rate card to validate membership against, so whether a pinned model is servable is answered by the first call, as model_not_priceable against real observed cost.
cap_micro_usd and max_task_micro_usd are positive integers of micro-USD, not cents: a turn’s reserve is routinely sub-cent. period is total|day|week|month|year. alert_at is a 0–1 fraction. hard defaults true.An agent with no cap is not creatable — a null cap means no cap and no per-agent spend record at all.max_task_micro_usd is checked before each model call against that task’s own spend — its root run plus every sub-agent it delegated to — using the same reserve the cap is checked against. Crossing it ends the task: status: failed, non-retryable, with both numbers in error. It does not park the way the cap does, because a per-task ceiling never resets.🔴 Measured against deployed staging (task-def 41), that paragraph is intent and not behaviour. An agent with max_task_micro_usd: 1 and a period cap large enough that it could not 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. Independently reproduced on a second agent (21,464 and 22,871 micro-USD against the same ceiling). The field is required at create and is currently not a control.Crossing the cap parks the task at status: braked, and that state is terminal in practice: the runtime’s board claims only queued, running and waiting, and nothing moves a task out of braked — not the period rolling over, and not a PATCH raising the cap. Raise the cap to let the next task run, and re-send the work.🔴 The cap is an ADMISSION gate, so the first task overshoots it. Measured with cap_micro_usd: 1, period: total, hard: true: task #1 ran to status: done and spent 22,534 micro-USD; task #2 returned status: braked, blocked_reason: budget, spent: 0. The park path is exactly as specified from the second task onward — a fresh agent gets one unbounded task.
Send only the fields you want to change; the rest take platform defaults and are written onto the record, so GET shows the numbers the agent will actually run with rather than a pointer to a default that can move under it.An unknown key is an error, never an ignore. sliceWallMs is 30,000–840,000 (840,000 = 14 minutes, a hard guard under the platform’s 15-minute alarm ceiling — it bounds a wake, never a task). turnWallMs may not exceed sliceWallMs; toolTimeoutMs may not exceed half of it.
min hour dom mon dow. Seconds are not a field. text is required — a schedule with no work is a bug that fires forever. tz is IANA, default UTC. Takes an object or an array.
Vault entry names. The server rejects strings shaped like values (sk-…, ghp_…, nv_sk_…, vt_…, xox[baprs]-…) — that check is here rather than deeper because after this point the string is already in a request log.
Reads return { requested, effective } per tool — a flag cannot widen the Account Kit, so requested: true with effective: false means the kit removed it. web_search resolves to naive’s search primitive under the same child project’s kit, so a kit with search off removes the tool whatever the flag says. Types are in the table above.

Send work — one route, two representations

POST /:id/tasks is a single route. The Accept header decides.
Body: text (required, ≤ 256 KB), payload, mode (act|ask), priority (normal|low), window (asap|standard|flex), not_before, idempotency_key, metadata. Every other key is a 400.
🔴 mode, priority and not_before take their own values and NOTHING else — case included. Until 2026-08-19 this body was hand-parsed field by field with no rule for a value it did not recognise, so all three were accepted and silently changed:The first is the one that matters. mode is a permission, not a phrasing: ask hands the agent the READ-ONLY tools only, so "ASK" with the wrong case did not fail closed to read-only — it fell through to act and returned a write-capable agent under a 2xx. An absent mode still means act; a present-and-unrecognised one is refused rather than defaulted in either direction.not_before is ISO-8601, or epoch milliseconds as a string. A timestamp already in the past is accepted and means run nownot_before computed from the current clock is in the past by the time the request is parsed, so a refusal there would depend on how long the call spent on the wire and would let an idempotent retry come back 400. Only a value that is not an instant at all is refused.
202 means durably queued. The row is inserted and the alarm armed in one transaction before the response returns, so a runtime outage is a 503 and never a silent delay.source is platform-set. No request body may set it — a caller-settable source makes the observability column a lie.On the streaming branch, frame zero is event: task_created carrying { task_id }, so a streaming caller holds a real id before the first real event. The stream subscribes at after=-1, which closes the race between the enqueue and the attach: anything appended in between is replayed, not lost.
🔴 A non-asap window needs a model that declares one, and is REFUSED otherwise. The gate lives in the model port rather than at this edge, so a non-asap window on a model without the tier is accepted here with a 202 and then refused at the first model call with window_unavailable — never downgraded to asap. It surfaces as a failed task, not as a rejected request, and nothing is spent: the gate runs before a provider is chosen.The refusal is deliberate. A window accepted and served as asap under another name would report a discount nobody bought, with no field downstream disagreeing — which is exactly what it did before the gate existed. On a model without the tier, send asap or omit the field.
🔴 Measured against deployed staging, window: "standard" on this route is silently replaced by the agent’s own completion_window. A 7-cell matrix over agents whose defaults differ: requested asap stored asap and requested flex stored flex on every agent, but requested standard stored the agent’s window (agent asapasap; agent flexflex; agent standardstandard, which masks it). The edge is not at fault — window=bogus returns a typed 400 invalid_input with details.allowed = [asap, standard, flex], so the request arrives valid. The fix is in the runtime source; the deployed edge worker is behind it. Until your deployment’s worker is current, read the window back off the task record rather than trusting the request.🔴 And a non-asap window on a non-GLM model was served and billed rather than refused. An agent on anthropic/claude-sonnet-5 with completion_window: flex ran to status: done, spent 8,054 micro-USD, and its task recorded window: "asap". No window_unavailable on any route. Two sub-facts: an agent’s completion_window is not inherited by its tasks at all, and the only model the gate admits was not servable on that host, so the refusal was unreachable there by any route.
A per-task "window" takes all three values, as does the agent-level completion_window. ci/agents-window-parity.test.ts fails if any surface goes back to spelling the window set by hand. A schedule entry’s window is accepted and dropped — see the field table above.A task’s window is not the agent’s knob. Here it selects the tier the call is priced at. The run’s tool fan-out, sub-agent width and batch hint come from the agent’s completion_window alone, because a run declares one strategy in its manifest before its first turn.Omitting the field on a task inherits the agent’s completion_window. It does not fall back to asap. The edge forwards an absent window as undefined and the runtime resolves it, so exactly one component holds that default. An earlier release did read an omitted window as asap; if you are working from notes written against that behaviour, it changed.A misspelled window is a typed 400 invalid_input here, with details.allowed = [asap, standard, flex] — it never becomes a silent asap.

The event log

Query: task, after, limit, kind.
after is a POSITION, never a time. Replay is strictly greater than the value passed, so a resume loses nothing and duplicates nothing.Retention is 20,000 rows, no TTL. An attach older than the floor answers with earliest_seq and truncated: true and starts there.🔴 kind is accepted and SILENTLY IGNORED on a deployed host. Measured: ?kind=declared returned all 102 events spanning 12 distinct kinds, and ?kind=no_such_kind_at_all returned the same 102. The edge forwards the parameter; the runtime’s pager drops it. A caller filtering by kind receives the whole log believing it is filtered — filter client-side until this closes. ?task, ?limit and ?after are all honoured correctly.
Last-Event-ID is honoured when after is absent — that is what makes a browser EventSource resume rather than replay from the start.

Deliverables

kindreport|doc|code|dataset|image|video|pr|deploy|other. Inline text is allowed up to 256 KB instead of a storage_key. 20 per task, 25 MB each. An agent’s own deliverables carry the kind the model chose: the runtime’s deliver tool offers those same nine words and sends the one it was given, for an inline delivery and for a published file alike. It is other when the agent named none, and other — with the refused word echoed back to the model in the tool result — when it named something outside the list. Before that field existed on the tool, every runtime-written row took the route’s other default, whatever the task asked for.
🔴 final: true requires text, storage_key or no_artifact_reason and is refused otherwise — without that, “deliver” degrades into a status update.upload_url / download_url are null when the deployment has no storage sink configured. That is a deployment state, not an error; send text instead. A minted download_url is unauthenticated once issued and lives one hour — never cache it.
🔴 final: true returned 500 internal_error on every deployed host — FIXED in the API, not yet on the running staging image. Bisected to that one field: a body of {agent_task_id, title, kind: "report", text, bytes, final: true} answered 500, and the identical body with final: false answered 201. The cause was a schema mismatch, not a validation rule: recordDeliverable inserted status: "final" for a final manifest, while the agent_artifacts_status_check constraint admits only created | reviewed | published | failed | deleted. Migration 070 added the deliverable columns and never widened the CHECK. The fix records every deliverable as created — finality is a property of the DELIVERY, not a point on the artifact’s review lifecycle, and it is already carried in metadata.final, which is the copy this API has always returned as final.The related question is now answered, and the answer is a SECOND, STILL-OPEN defect. Four real agent deliver(final: true) tool calls each reported success to the model and emitted a delivered event, yet GET /deliverables listed only manifests written by hand. The runtime never attempted the write: it did not 500 and get swallowed. deliverCall publishes only when the model passes a file, and those four deliveries were inline text — which is exactly what their noArtifactReason: "the output is inline text" records. And the one runtime writer that does exist, the sandbox sink’s publish, posts no final field at all, so a runtime-written manifest is always final: false.Consequence: fixing the 500 does not by itself make a delivered artifact appear here. A final: true manifest is currently only reachable from a direct API, SDK, CLI or MCP caller. Until the runtime writes one, GET /deliverables remains empty for an agent whose answer was inline text, and the answer is retrievable only from the delivered event on the event stream and from the job_complete webhook.POST .../deliverables/upload itself is fine — 201 with a storage_key and a presigned PUT — so the route-shadowing hazard the source warns about is correctly avoided.

Spend

Every paid component appears including the zeros. Credits are decimal strings. There is no projection line — the platform does not forecast a bill. Two records, never summed. spent_credits / spent_micro_usd is what the agent’s model calls COST, metered by the agent’s own runtime — the same figure the budget brake reads. billed_credits is what credit_transactions holds for this agent, i.e. what an invoice would show. Each group’s source says which of the two it came from. unpriced_calls counts metered calls that neither the vendor nor a rate card priced: they are inside calls, add nothing to the money, and are not free.
🔴 This route reported ZERO for agents with substantial real spend, and why is worth carrying. Measured: an agent whose own task board summed to 41,125 micro-USD across 16 tasks returned spent_credits: "0.0000" with calls: 0 on every component group; two further agents (44,335 and 21,277 micro-USD) did the same. Reproduced afterwards on a fresh agent: board spent_micro_usd: 666, route "0.0000".The route summed credit_transactions rows carrying metadata.agent_id, and nothing writes one — the runtime calls the model vendor directly, so an agent’s inference never touches a naive billing path. inference is therefore read from the runtime’s meter. This was always an independent reporting defect and never the cause of the budget findings above; the period brake fires because it reads that same meter.billed_credits: "0.0000" beside a non-zero spent_credits is the honest state of the platform rather than an error: agent model spend is metered and reported, and is not yet on an invoice. A deployment with no agents runtime answers metered.available: false and the ledger’s side alone; one that has a runtime and cannot reach it answers 503 agent_runtime_unavailable rather than a zero, because a zero was the defect.

Inbound webhooks

These three routes are the control surface for an agent’s inbound URL, not a receiver. The door itself is the platform’s existing POST /webhooks/hooks/:slug, which is mounted before express.json() for a raw body, verifies HMAC-SHA256(secret, "<X-Naive-Timestamp>.<raw body>") on a five-minute window, and answers 404 for an unknown or inactive endpoint so it is never an existence oracle.
🔴 The secret is in that response and in no other. GET reports secret_set: true. A secret a list can return is a secret in every log that rendered a list — if it is lost, revoke the endpoint and mint a new one.
A verified delivery enqueues a task with source: "webhook" and the provider’s body in payload, returning as soon as the row is durably written — the agent’s work never runs on the provider’s clock.

Errors

agent_not_configured is recoverable: re-push the agent’s configuration with a PATCH /v1/agents/:id and retry the task. It is not retryable on its own.

Where the error surface drifts from this table

Measured against deployed staging. Each of these is a real answer you may receive, so they are documented rather than left for a caller to discover. Everything else in the table above was exercised and answered as documented, including tenancy (404, never 403, for a foreign id) and the shape of invalid_input (error.code / message / request_id / details.field).