Overview
RequiresAuthorization: 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:
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
Every field of the create body
Twelve top-level fields onPOST, 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.
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
model — must be PINNED
model — must be PINNED
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.budget — required, and integer micro-USD
budget — required, and integer micro-USD
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.limits — sparse in, materialised out
limits — sparse in, materialised out
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.schedule — five cron fields
schedule — five cron fields
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.secrets — names, never values
secrets — names, never values
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.tools — narrows only
tools — narrows only
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.
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.
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 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
task, after, limit, kind.
Last-Event-ID is honoured when after is absent — that is what makes a browser
EventSource resume rather than replay from the start.
Deliverables
kind ∈ report|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.Spend
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.
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 existingPOST /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.
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).