Skip to main content
createClient<typeof config>() adds the durable-runtime surface to the same object you already use. Nothing legacy moves: NaiveClient extends Naive, so one credential, one host, one transport.

A team is a pair, not a name

naive.teams.support is a reference, not a handle. The addressable unit of the durable runtime is (team, tenant) — the same pair the runtime itself uses — so you bind a tenant before you can do anything:
forTenant verifies; it does not get-or-create, and it costs one round trip to say so. It resolves GET /v1/users/{id} first, so an unknown tenant id fails with not_found here instead of silently succeeding and writing a run against a tenant that does not exist. There is deliberately no unchecked twin — a twin would become the default and the check would become decoration.
Without a naive.config.ts to type the client, naive.teams.support does not typecheck. Use the string twin, which behaves identically at runtime:

Agents are derived, never addressed

An agent handle narrows intent, not authority. Every call still travels on the company key with the tenant’s seal. t1.tools() returns the tenant’s full Account-Kit-gated meta-toolset — it is not filtered to that agent’s declared can list. Server-side enforcement is unaffected; what is missing is the client-side narrowing. Do not read support.agents.tier1 as an isolation boundary.Unknown agent names do not throw: no mounted route knows a team’s roster, so a runtime check would have to be a fabricated allowlist. The type layer (keyed off your config) is where a typo is meant to be caught.

What is wired today

Three namespaces reach mounted routes.

runs — the run ledger

list() and events() both return { items, next_cursor } — keyset pages, not { runs, count }. list() takes { limit, cursor } and no status filter.
/v1/runs is company-scoped and scopes itself by the credential, not by the URL. There is no team or tenant_user_id query parameter on the route. Under an operator key, teams.support.forTenant(x).runs.list() and teams.billing.forTenant(y).runs.list() return the same rows. The team and tenant in the path are addressing, not filtering. A sealed agent key sees only its own runs; a human session and the config root see the whole company.watch() polls. The company-scoped poller (naive.runs.watch) reads GET /v1/runs/{id}/events, which returns the whole ledger and accepts no after cursor — that cursor is client-side, so cost grows with ledger length rather than with new events. The team-scoped one (support.runs.watch) reads GET …/runs/{id}/events, which does take ?cursor= and pages by seq, so its cost grows with new events.Neither uses a stream. GET …/runs/{id}/stream is served for a durable tenant — it proxies the runtime’s transcript as SSE, and naive teams watch consumes it — but this client does not call it. Reach it with legacy.invoke or the CLI if you need frames as they arrive.

approvals, brain, memory — real, but not team-narrowed

These three are the same objects naive.forTenant(id) gives you. They resolve to the identical /v1/users/{tenant}/… base, because the tenant is the only scope those routes have — there is no team dimension on any of them. teams.billing.forTenant(x).approvals and teams.support.forTenant(x).approvals are one queue, not two. And see memory before using support.memory.add(): it sends a request, it does not write a row.

What is wired, and what refuses

Nothing on this client returns {}, and nothing refuses at a route that exists. The rule is one line: a method refuses in the client only when the route is not mounted. When a route IS mounted the call goes out — even if the server answers 501 not_configured, because “there is no route” and “the route refuses” are different instructions. Only the second arrives with error.details.missing naming each absent dependency, written by the people who know, and only the second starts working the day that dependency lands without a new release of this package.
And whether a sent call succeeds depends on the tenant’s runtime. A tenant runs on the durable runtime or on the frozen legacy one — one column, company_containers.sidecar_url — and /v1/teams branches on it. The same method against two tenants gets two different outcomes, and both are correct.This client does not pre-empt that branch, and must not: it would have to know which runtime a tenant is on, which is a fact only the server holds and which changes underneath it. Read it off the response instead — every success carries provider ("durable" | "hermes"), every refusal carries error.details.runtime, and plan() reports it directly.Both sides of the branch are live. registerVettaRuntime() — the only writer of company_containers.sidecar_url for the durable runtime — has exactly one production caller, services/placement.ts, reached from naive up when a teams: block declares runtime.durable(...) and the operator supplies NAIVE_DURABLE_CREDENTIAL_<TEAM> out of band. migrate() still refuses: moving an existing tenant is not symmetric, so it stays an operator act rather than a call. Keep reading provider rather than hard-coding it: a tenant moved between runtimes changes what these calls do with no release of this package.

Wired to the (team, tenant) address

The four 🔴 rows answer 200 with nothing in them for a durable tenant, and that is the most dangerous shape on this table. They are not refusals you can catch — they are successful empty results.All four read naive’s task mirror, which is written by the legacy close path. A tenant placed on the durable runtime never enters it, so the query is well-formed, the response is well-shaped, and the answer is empty for a structural reason the response does not state.
  • diagnostics()findings: []. Its unverified finding in particular can never appear on that runtime: it is a query for a mirror value nothing writes there.
  • spend.cost()buckets: []. See Cost for the two conditions that have to hold before a durable tenant’s model spend reaches this ledger at all.
  • runs.events()items: [], and therefore runs.watch() polls an empty list forever. A durable run’s transcript exists — it is served, as SSE, at GET …/runs/{id}/stream — and this client does not call it. Until it does, use the stream directly for a durable tenant rather than watch().
board.list() and board.get() are the two reads that were given a durable lane, which is why they are the two that are real on both. Branch on provider, which every response that has a durable lane carries.
A 501 for a durable tenant means something narrower than “not built”. It names the verb the runtime already implements and says which control-plane translation is missing, so error.details.missing distinguishes not built from not reachable from here — a distinction a caller can act on and a blanket 501 destroyed. Where the refusal came from the runtime itself, its status is preserved and its own sentence is carried through verbatim in error.details.runtime_said.submit() resolves with manifest_digest: null and manifest_digest_unavailable_because beside it: the durable runtime reports its applied digest on GET …/plan, not on an admission. Read the sibling; do not read the null as “no manifest”.board.stop() stops the team, not the board — the mounted route is team-scoped (POST …/stop). It lives on board only because that is where the published surface put it. For a durable tenant it is served: it stops the dispatcher claiming further work and fences it against re-arming (start-loop resumes). For a hermes tenant it is 501 — a team-level stop is a state of the Durable Object, and the legacy runtime has no equivalent row to set; a legacy run is stopped one at a time through the sidecar.Read the answer, not just the status. It carries cancelled (scheduled ticks removed), in_flight (attempts already handed to a member), stopped_at, and is_pause_not_decommission: true. That last field is the point: this is a pause. Cards keep their leases and their attempt budgets, submit() still admits work, and an attempt already in flight is not recalled — there is no abort channel into one, so it runs to its end and still spends. in_flight is how you know whether that number is zero. The runtime refuses a per-run kill (POST …/runs/{id}/stop) by design, for the same reason.spend.cost({ by }) accepts exactly one value. The route throws invalid_input: by must be "action" (the only bucketing this build can compute) on anything else, so the parameter is typed to what it accepts.

The brains, from a team handle

support.brain and support.brains are the same objects naive.brain / naive.brains give you. They are here because a handle that offers the value but not the collection sends you back to the root for half of one surface — not because the team or the tenant scopes them. brainRouter reads the company id; the :tenant segment selects the mount, not the rows. A 501 arrives as an ordinary NaiveError with code: "not_configured", and its details.missing is the list of things the server is waiting on:

Refuses in the client — the route does not exist

Eight methods have no mounted route and each throws a typed NotImplementedError naming the exact METHOD /path it would have called, sending nothing. The registry is data (MISSING_ROUTES), a test walks every namespace and asserts the refusing set is exactly that table, and a second test asserts every row is absent from the published OpenAPI spec — so a row cannot survive the route landing.
For anything this client does not model yet, legacy.invoke(method, path, body?) is a raw request on the same credential and host. It names no noun and applies no shape, which is exactly why it is safe for a route with no handle:

The frozen legacy surface

naive.legacy is born deprecated. It is the frozen legacy orchestration runtime: every route keeps answering, nothing is removed, no response shape changes, and it accepts no new capabilities. It exists as one clearly-marked namespace rather than scattered across the root.Use instead: naive.teams.<name>.forTenant(id) for anything it can already do today, and the REST surface for anything it cannot. There is no sunset date.

Known gaps

  • No manifest-digest fence. defineConfig returns your config by identity and computes no digest, so the client has nothing to send and nothing to compare. A local config’s types can therefore be newer than the applied manifest. The option is absent rather than accepted-and-ignored.
  • naive plan at project scope has no SDK equivalent, for the same reason: its central field is that digest.
  • t1.tools is not narrowed to the agent’s can list — see the warning above.
See also: In-agent SDK for the same runtime seen from inside an agent, and Governance for what is and is not reachable on the policy surface.