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 aredefineConfig 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.
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.
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.
placement, never inside it. The apply output
carries manifest.status — applied 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 loopsqueued; binding it (naive teams apply <team>) and re-runningnaive upregisters 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.
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
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 runtimeboard 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
- Orchestration — the frozen legacy runtime, and the mapping off it
- Approvals — how a parked action is resolved
- Brain — the partition a team binds
- Infrastructure as code — the rest of
naive.config.ts