Skip to main content
A team is N agents behind exactly one lead, bound to one partition of one company brain. It is the unit the durable runtime schedules, governs and bills. A team that names no runtime runs on the durable runtime — see Choosing a runtime for pinning a team, or one member, to hermes.
What is served depends on which runtime the tenant is on. Of the 32 /v1/teams/** operations, a tenant on the durable runtime is served 22, a tenant on the frozen hermes runtime 13, and 10 are refused for both. A refusal is 501 not_configured naming the missing dependency for that tenant’s runtime in error.details.missing. Every response carries provider; every refusal carries error.details.runtime. See What answers today for the exact list.A tenant is placed on the durable runtime only by naive up — when a teams: block declares runtime.durable(...) and the operator exports NAIVE_DURABLE_CREDENTIAL_<TEAM> out of band. POST …/migrate is 501; moving a tenant is an operator act, not a call — see the durable runtime.The credential is the last of three checks, and on api.usenaive.ai the first two now pass — as of 2026-08-06 that deployment has the durable runtime and both cutover prerequisites are signed off, so runtime_credential_required is the refusal to expect and supplying the credential makes the placement succeed. On any other deployment the first check may still refuse. Either way, read placement.code: is it on this deployment? runtime.hermes() is the lane the apply places itself.

Declaring a team

naive.config.ts

Things you cannot write

The first three are compile errors — you find out in your editor. The last two are defineConfig refusals, so you find out when the config is evaluated — naive up, naive up --plan, or your own build — before anything is applied. 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.

Choosing a runtime

runtime: on a team is the default for its roster, and the default’s default is runtime.durable(). Three shapes, in order of how much you have to write:
naive.config.ts
naive.config.ts
naive up reports a mixed team once, with the runtime each role landed on:

What each choice actually changes

pool is optional on runtime.hermes(). Omitted, the team lives in the company’s one hosted-hermes container — which is where every hermes team lives today. Given (runtime.hermes({ pool: "warm" })), the name must match a pool declared under the config’s top-level runtime: block: an unknown name is hermes_pool_unknown at define time and invalid_input at plan/apply. A valid name is recorded on the team’s agent rows as metadata.config_pool; it does not change which container is claimed.
The two lanes of a mixed team do not delegate to each other. A hermes agent delegates over the one kanban.db in its container; a durable agent over its own object. There is no bridge, so edges: across the lanes is a define-time error (edge_crosses_runtime) rather than a card that is accepted and never arrives. Hand each lane its own work — the lead’s naive teams submit for the hermes roster, and the durable member’s own runs — or put both ends on one runtime.The practical shape that works today: a hermes team with durable specialists. The reverse (durable lead, hermes member) is refused, because the hermes profile would have no gateway to be dispatched from.A mixed team’s durable half is placed like any durable team, so it needs NAIVE_DURABLE_CREDENTIAL_<TEAM> in the environment of the naive up — without it the apply still creates the hermes half and reports the durable half as refused / runtime_credential_required, non-zero. On a deployment with no durable runtime the same half is refused earlier and for a different reason (runtime_not_configured), credential or no credential.
Which runtime is recorded on the row, per agent. agents.metadata.config_runtime is hermes or durable for every config-declared agent, and it is what keeps the legacy recovery sweep from claiming a container for a durable tenant. naive up --plan prints role_runtimes for a mixed team, so you can see the split before anything is written.

Model, rubric and spend — durable-manifest fields

A durable team’s apply delivers a company definition to the runtime — members, grants, gates, budgets, rubric — and binds the digest the runtime compiled it to. That is where these fields become enforcement: A spend bucket names the team (a company-level budget) or one of its durable roles (a role-level budget); anything else is refused by name. hardSpend adds a reservation under a cap — reservationCents may not exceed capCents.
Delivery is reported per team, beside placement, never inside it. The apply output carries manifest.statusapplied or noop means the runtime bound the digest and the table above is live; refused, unreachable and delivered_not_applied each carry the refusing side’s own words; skipped means placement did not succeed so there was nothing to deliver to. A placed team with a refused manifest is placed and not enforced, and the report says exactly that. These are durable-runtime fields. Declaring any of them on a hermes team is not an error — the apply succeeds — but the plan and apply name every one of them in the team’s unconsumed list with the runtime that cannot serve it, because a silent drop is the defect this surface exists to remove. In a mixed team only the durable lane is projected: a hermes-pinned member’s model is reported, not delivered.

The agent’s loop — a declared cron wake-up

agent({ loop }) is the recurring wake-up: the platform’s cron system wakes the role on a schedule with a goal as the run’s prompt, no human in the loop.
Both lanes register it, against two different schedulers. On runtime.hermes apply registers the schedule with the container’s cron system — the same one naive cron list reads. On runtime.durable it registers with the runtime’s own /cron, which fires on a Durable Object alarm that needs no warm container, and which naive teams schedules lists. Either way re-applying an unchanged config converges instead of stacking duplicates, and every firing is reserved to the role that declared the loop — the declaration is per role and so is the enforcement. The apply report carries what happened per agent. loop.status is registered when the scheduler took it, queued when it did not, and unschedulable when nothing could compile the cron. A queued loop on the durable lane carries loop.reason — the runtime’s own sentence, so “the manifest is not bound yet” and “this team already holds sixteen timers” do not read the same. Two durable-lane rules worth knowing before you write one:
  • The manifest fences it. The runtime refuses to schedule against a manifest it has not bound, exactly as it refuses submit. A team whose manifest was refused reports its loops queued; binding it (naive teams apply <team>) and re-running naive up registers them.
  • Sixteen per team. That is the runtime’s cap. A config declaring more is refused at plan time rather than half-applied — split the roles across two teams, since each team is its own tenant with its own sixteen.
Deleting a loop from the config unschedules it: apply cancels the timer it registered under the default loop:<team>:<role> name and reports it under loops_unscheduled. A loop that declared its own loop.name is outside that namespace and is not swept — remove it with naive teams unschedule.

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 user fallback. --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 control plane is addressed per (company, tenant), but the deployment is provisioned per company, so every “tenant” answer below is 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

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 of every unmet prerequisite — deliberately not 404, 403, or an empty 200. Where a refusal names a legacy route it describes what that tenant has, not a recommendation to use it: writing via naive tasks create goes to the frozen runtime, a different execution model with a different governance path. A refusal from the runtime itself is carried through unchanged, with error.details.runtime_said holding the runtime’s own words.

Next steps