Skip to main content
A team is N agents behind exactly one lead, running on one runtime, bound to one partition of one company brain. It is the unit the durable runtime schedules, governs and bills.
What is served depends on which runtime the tenant is on. The /v1/teams/** surface is mounted, and of its 32 operations a tenant on the durable runtime is served 22, a tenant on the frozen hermes runtime is served 13, and 10 are refused for both. A refusal is 501 not_configured naming the missing dependency for the runtime that tenant is on, in error.details.missing.Which runtime is one column — company_containers.sidecar_url — so the decision is per tenant. Every response carries provider; every refusal carries error.details.runtime. The table under What answers today is the exact list, taken from the router. Nothing on this page is a plan; it is either served or it is named as refused.No tenant can be put on the durable runtime by a request, and that is deliberate. The only function that writes that column to the Worker, registerVettaRuntime(), is reachable from exactly one production path — naive up, when a teams: block declares runtime.durable(...) and the operator exports NAIVE_DURABLE_CREDENTIAL_<TEAM> out of band — and POST …/migrate is still 501, because registering overwrites the tenant’s legacy coordinates and the way back is not symmetric. So a tenant naive up placed answers provider: "durable" and gets the 22 durable operations; every other tenant answers provider: "hermes", and moving one is an operator act rather than a call. See the durable runtime.

Declaring a team

naive.config.ts

Three things you cannot write

These are compile errors, not runtime failures — you find out in your editor. brain: acme.view({ can: [] }) — an empty ability list — is how an agent gets no brain access. There is no second concept for “none”, and no field anywhere in which a second brain can be named: acme.partition() and acme.view() are methods on the brain value itself.
A BrainView is a plain object type today, not a branded one. An object literal with the same shape typechecks where a real acme.view({…}) is expected, and partitions are matched by name, so a second brain() value declaring a same-named partition binds without complaint. Treat “only the brain you declared can be bound” as a convention that the type system does not yet enforce.

Addressing: (company, tenant, team)

A team is not addressable on its own. Every runtime operation names three things:
  • the company — taken from the API key you present, never from the URL;
  • the tenant user — the end-user this work is for, --tenant <tenantUserId>;
  • the team — the key you declared under teams:.
--tenant is required on every subcommand except list. There is no default and no fallback to a “default” user: submitting one customer’s work under another customer’s ledger is not a mistake worth making convenient. --tenant @self resolves the credential’s own subject explicitly. The REST form is /v1/teams/{team}/tenants/{tenantUserId}/….
One deployment currently serves one company. The durable runtime’s control plane is addressed per (company, tenant), but the deployment this API talks to is provisioned per company rather than shared. Read every “tenant” answer below as scoped inside one company’s deployment.

submit is the work verb

There is one verb for handing work to a team, and it is submit. It is the same operation on every surface — one action id, one fence, one audit row.
--brief <file> (or - for stdin) exists because prose on argv is mangled by every shell — quoting, !, newlines. Use it for anything longer than a sentence. naive tasks create, naive ceo run and naive objectives create all map to submit. They keep working; see Orchestration.

Watching a run

naive teams watch works for a tenant on the durable runtime and refuses for one on hermes. GET …/runs/{id}/stream proxies the runtime’s own transcript frame for frame; for a hermes tenant it answers 501 not_configured naming three things that would make the stream dishonest there — run-event frames are caller-forgeable on the legacy table, there is no trace_id column, and usage is recorded on finish so a long stream cannot be rate-limited. To read a finished run on either runtime, use naive teams runs and the paged transcript.The durable stream carries no trace_id either, and the response says so in a header rather than letting a consumer assume one is coming. It is bounded by the runtime rather than by naive: the Worker closes its own stream after 300s and rate-limits its front door.
watch requires --run. It emits NDJSON — one object per line — and is resumable: --cursor <seq> is sent as Last-Event-ID, so a dropped connection resumes at a position, not at a timestamp. --fail-on-terminal exits 3 when the final status is unverified, failed or blocked, which is what you want in CI. Tool arguments are printed as args_digest rather than as values. --show-args prints them and requires an interactive terminal, so a CI log cannot accidentally capture arguments that carried a secret.

Approvals

Approvals are the one write on this surface that is fully served today.
--because is required. The reason is recorded on the decision, so an approval that was granted always carries why, and a later reader is not left inferring it. POST …/approvals/{id}/decide is a second address for one write path, not a second write path: it calls the same execute/deny code as /v1/users/{user_id}/approvals/{id}/approve, so the human-resolver rule and the no-self-approval rule hold identically. An agent cannot resolve its own approval through this address either. See Approvals for the resolution rules.

What answers today

Measured against the mounted router, not against a plan. Served for every tenant, on either runtime board and board/{card} answer from different stores depending on the runtime — the durable runtime’s own board for a durable tenant, the legacy mirror for a hermes one — which is why every response names its provider. Served on the durable runtime; 501 on hermes, naming the legacy equivalent Refused for both, each naming what is absent A refusal is a 501 with error.details.missing as a list — if you fix one prerequisite there is a second, and you are told about it now rather than after another round trip. It is deliberately not 404 (which would say your address is wrong), not 403 (which would say you are not allowed), and never 200 with an empty list (which would say this tenant has no work). Where a refusal names a legacy route, it is telling you what that tenant has — not recommending you switch to it. Reaching for naive tasks create to make a durable operation appear to work writes into the frozen runtime, which is a different execution model with a different governance path. A refusal that came from the runtime itself is carried through unchanged: the status is preserved and error.details.runtime_said holds the runtime’s own sentence, because whoever runs the cutover needs the runtime’s words rather than this API’s paraphrase.

Next steps