Skip to main content
The durable runtime is where new agent work belongs. It replaces the legacy orchestration runtime (event router, runtime.pool()) with a different unit of address: not “an agent profile in a pool”, but a team, per tenant.
Legacy orchestration is frozen, not removed. Every existing route, CLI command and config keeps answering, and none of it is scheduled for deletion. This page is where new work goes; it is not a migration deadline.

The address is a pair

A team is declared once and instantiated per tenant. Every runtime operation therefore carries both:
That pairing is the whole design. In the legacy runtime a “team” was a set of agent profiles sharing a pool, and tenancy was a property of each profile; here the tenant is part of the address, so one customer’s board cannot be reached with another customer’s id by forgetting a filter.
runtime.durable() and runtime.hermes() are a closed two-member union. runtime.hermes() is the legacy runtime, declarable and marked deprecated so an existing config can name what it already runs on; runtime.pool() is untouched.

Which runtime is this tenant on?

Every answer below depends on it. A tenant is on the durable runtime or on the legacy orchestration runtime, and which one is a single column — company_containers.sidecar_url. There is no third state and no global switch: the decision is per tenant, one row at a time.
Exactly one production path writes that column to the durable runtime, and it is naive up placing a newly declared team. Every other tenant reads provider: "hermes".providerFor() answers from isVettaSubject(), which reads the row. The only function that points a row at the Worker is registerVettaRuntime() in packages/api/src/services/vetta-runtime.ts, and it has exactly one production callerpackages/api/src/services/placement.ts, reached from the apply route when a teams: block declares runtime.durable(...) and the operator supplies NAIVE_DURABLE_CREDENTIAL_<TEAM> out of band. It is still not tenant-addressable: it is not mounted on a route a tenant can reach, and POST …/migrate is one of the ten operations that refuse on both runtimes.So a tenant can answer provider: "durable" today, and the durable column of every table on this page is what that tenant is served. Confirmed on deployed staging: a tenant placed by naive up answers GET /v1/teams/support/tenants/{id}{"provider":"durable"}, while a tenant that was never declared into a durable team answers {"provider":"hermes"}.Placement refuses far more than it permits, and that is deliberate. Registering is irreversible in the way that matters: it overwrites the tenant’s Hermes coordinates, and coming back costs a fresh ECS task and a fresh volume with the old /data gone. So placeTenant permits only two of the four states it can find — N0 (no company_containers row: placing destroys nothing) and N3 (already on the durable runtime: converge by comparing, never re-register) — and refuses N1 (a claim in flight) and N2 (a live Hermes container) outright, because a config edit must never be able to strand a running ECS task. The hermes-ward direction has no flag at all. That is why POST …/migrate sits in the refused set rather than being wired: moving an existing tenant is a runbook, not a request. packages/api/src/__tests__/vetta-runtime-uncalled.test.ts pins the caller list at exactly one, so a second entry point cannot arrive by accident.
Read every number on this page against one tenant. “Does POST …/submit work?” has no answer without one — it is served for a durable tenant and refused for a hermes tenant, and both responses are correct. Every response on this surface carries provider ("durable" or "hermes"), and every refusal carries error.details.runtime, so you never have to guess which you are reading.

What reads real rows today

Of the 32 operations on this surface, a durable tenant is served 22 and a hermes tenant is served 13. Ten are refused on both. The 13 a hermes tenant is served answer from real tables (tasks_mirror, task_runs_mirror, task_events_mirror, agents, tenant_spend_events) with keyset cursors, and that number has not moved. The durable runtime gaining nine operations cost the frozen one nothing — ci/runtime-surface-counts.test.ts pins it as an equality, not a floor, so a hermes tenant gaining or losing an answer here fails the build. GET …/roster and GET …/plan read agents, which is a real per-tenant table on either runtime, and …/approvals reads naive’s own queue. Those three are real for a durable tenant. The six marked mirror are not.
Six of these read naive’s task mirror on BOTH runtimes, and a durable tenant has no rows in it. The mirror is written by the legacy close path; a tenant whose sidecar_url points at the durable runtime never enters it. So for a durable tenant the six rows marked mirror above do not fail, do not refuse and do not say they are empty for a structural reason — they answer 200 with nothing in them:…/diagnostics is the reason the unverified finding never appears for a durable tenant either: it is a query for tasks_mirror.verification_status = 'unverified', and there are no rows to match. unverified is unreachable on that runtime on both the board and the diagnostics — the two places it is reported.GET …/tenants/{id} and GET …/board therefore disagree about the same tenant: the header reports an empty board because it counts mirror rows, while the board itself is fetched from the runtime and is full. Read the board for the board. The header’s counts are a hermes number.This is the one place on the surface where the section below — “It never returns { items: [] } — an empty list is a claim about your tenant” — does not hold. It holds of every operation that refuses; it does not hold of these five, because they were written before either runtime was a choice and they answer from the only store they know.
POST …/approvals/{id}/decide is a second address for one write path, not a second write path: it calls the same executeApproval / denyApproval the existing approvals routes call. Deciding an approval here and deciding it in the dashboard are the same act.
GET …/board?status= filters in memory on both runtimes. On hermes it says so on the wire (filtered_in_memory: true): the six board columns are a function of two legacy columns, so the filter cannot be pushed into SQL yet. It is correct, just not cheap on a large board. On the durable runtime it is filtered after the translation for a different reason — the runtime’s six states are a different partition, so a pushed-down ?status=awaiting_check would match nothing there and come back as an empty board with a 200 on it.One consequence is worth stating on its own: ?status=unverified on a durable tenant is a valid query that can only ever match nothing, because unverified is not in the image of the translation. See Board & cards.

The nine a durable tenant gets and a hermes tenant does not

These reach the durable runtime through the same control-plane seam every other runtime call already used — ${sidecar_url}/control/{company}/{tenant}{path} — and the runtime’s answer is returned, refusals included. For a hermes tenant each one answers 501 not_configured with error.details.runtime: "hermes" and the legacy route that does the equivalent job, because forwarding it to the legacy dispatcher would be a second write path into a frozen runtime. Three of the thirteen shared reads — GET …/board, GET …/board/{card}, GET …/runs/{id} — serve both runtimes but from different stores: tasks_mirror for a hermes tenant, the runtime’s own board for a durable one. A durable tenant has no rows in tasks_mirror, so serving the mirror to one would answer “this team has no work” for a team with a full board — a wrong answer with a 200 on it, which is worse than any refusal.
A durable card carries runtime_status beside status. The runtime’s six states (todo ready doing review done blocked) and this surface’s six (open claimed awaiting_check done unverified blocked) are not the same partitionreview has no column here and unverified has none there — so the mapped value and the real one are both reported rather than one being silently substituted for the other.

What is declared and refuses on both runtimes

The rest of the surface is addressable and honest: it answers 501 not_configured with error.details.missing naming each absent dependency by its real name, and error.details.runtime naming which runtime the refusal is about. It never returns { items: [] } — an empty list is a claim about your tenant, and these operations cannot make that claim. Where the durable runtime has a verb and only the control-plane translation is missing, the refusal says so and names the verb. That is a different fact from “the feature does not exist”, and it is the difference between a refusal you can ask for and one you can only work around.
POST …/stop stops the dispatcher. It does not stop the money already committed.This is the runtime’s only interrupt, it is served for a durable tenant (it is the seventh row of the durable-only table above), and it is the control its own per-run-kill refusal points an operator to — so what it does and does not reach is worth stating before anyone plans an incident response around it. It answers is_pause_not_decommission: true for the same reason.It does: mark the team stopped, cancel the recurring tick, and fence the tick so that a board transition arriving afterwards cannot re-arm it. That last clause is the load-bearing one. Round 2 measured a stop that did not hold: a card in flight at the moment of the stop finished, the reviewer requeued it, a second attempt was claimed, and the dispatcher re-armed itself 25 s later with no operator admission of any kind — the team’s own review transitions were the wake-up. The stop is now recorded before the cancel, so even a turn that dies mid-stop is fenced by the next tick.It does not: recall an attempt already handed to a member. There is no abort channel into one. That attempt runs to its end, spends what it spends, and its evidence lands in review where the fenced tick will not judge it until a resume. The answer carries in_flight for exactly this reason — an operator stopping a spending team needs to know that number is not necessarily zero.start-loop is the way back, and it exists so that an operator who stopped a team to look at it does not have to seed work nobody wanted in order to restart it.
A durable tenant has two approval queues and this surface shows one. GET …/approvals and POST …/approvals/{id}/decide are naive’s own — the approvals table — and they are real on both runtimes. The durable runtime holds a separate queue gating tool calls inside the Durable Object, and there is no control-plane head for it. Approving here does not release one waiting there.
GET …/runs/{id}/stream is served for a durable tenant even though two of its three original prerequisites are still absent, and neither is waved away. The forgery argument was about agent_run_events, a naive table that accepts caller-authored frames; a durable run’s frames are the Durable Object’s channel log, built by the runtime from what its members said, with no request body anywhere on the path — platform-set by construction rather than by a column that does not exist. There is still no trace_id (the response says so in X-Naive-Trace-Unavailable) and it is still not metered on open/v1/teams carries no meterPrimitive at all. What bounds it is the runtime’s own ceiling: it closes the stream after 300s and polls at a fixed 250ms, and its front door rate-limits reads at 120/min. Both ceilings are the runtime’s; a naive-side meter is owed the day teams is a registered primitive.
GET /v1/teams — the one company-scoped operation — also answers 501. A declared team’s name is stored in agents.metadata under a key spelled with a retired word, and the repository’s vocabulary gate holds every package that could host an accessor at its recorded ceiling. Listing teams therefore needs either a schema migration or a gate change, and neither is a route-level fix. Until then, address a team by the name you declared it with; the per-tenant operations above work.
Reading that table is the intended way to use this surface today: it tells you exactly what has to land before an operation becomes real, and every row disappears when its dependency arrives.

What still runs work

Nothing on this surface starts work yet — POST …/submit is in the refusing table. To actually run an agent today you use the legacy runtime (naive tasks, naive ceo, runtime.pool().start()), which is frozen and fully supported. The durable surface is the read and governance half of the cutover; the dispatcher is the other half.

Governance is the same governance

Every operation here sits behind the same session-or-key authentication and the same approvals machinery as the rest of the API. There is no second policy engine, no second approvals table, and no runtime-specific bypass — see the governance gateway and Approvals.