The agent runtime
How AgencyCore runs AI agents: a provider-neutral runtime that runs exactly one agent durably, with a swappable backend behind a single interface. The system at the level of architecture, core components, and the abstraction that lets us change providers.
At a glance
tl;drAgencyCore runs AI agents on a small, layered system. At the bottom is one job: run a single agent, durably, and record what it cost. Everything above that is composition, and the backend that actually executes the agent loop sits behind one interface — so it can be swapped without touching the rest of the system. Claude managed agents is the backend we run today.
The mental model worth holding: an agent is a config, a run is a process, the backend is pluggable. The runtime knows how to run one agent and nothing about workflows; workflows are just composition on top. And because the backend lives behind an interface, Claude managed agents is the current implementation, not a hard dependency.
System overview
interactive This is the inside of the agent_runtime package — the atom that runs one agent. Read top to bottom: the front door, the run logic, the support roles it leans on, and the provider seam at the bottom. Click a module to see what it does; the dashed line is the swap point. The composition layer that sequences runs into workflows and the shared Supabase / Redis / Inngest infrastructure live in the agent execution stack.
agent_runtime is plain async — zero Inngest, zero FastAPI routers. It knows how to run one agent and record what it cost. Durability and composition are added above it, in the execution stack.
The run logic speaks the AgentProvider protocol, not a vendor. Claude managed agents satisfies it today; any backend that satisfies the same contract drops in with nothing in the run logic changing.
Core primitives
vocabularyFive words carry most conversations about the runtime. The first four describe what the backend exposes; the last is our own unit of work.
A versioned config: model, system prompt, tool allowlist, environment template. Lives as one file in the src.agents catalog. Provisioned to the backend, not hand-built per run.
A container template — base image, env vars, attached MCP servers, vault bindings. Resolved to a backend environment_id when a session opens.
A running agent instance on the backend. Persistent filesystem, long-lived. The runtime holds its session_id and console_url on the run row.
An append-only entry on a session — user message, assistant turn, tool call, tool result. The runtime reads events to classify status and stream progress.
Our unit of work: one agent_runs row walking queued → running → terminal. Carries the runtime (provider) name, usage, and cost. This is what the rest of the system tracks.
The provider abstraction
interactive This is the seam. The runtime speaks one contract — the AgentProvider protocol — in neutral shapes. A provider is an adapter that translates that contract to a real backend. Toggle the provider to see the same six methods land on a different implementation; everything above the seam is unchanged.
| Method | Returns (neutral) | Claude managed agents |
|---|---|---|
resolve_ref() | AgentRef | list agents, match by name → (agent_id, version) |
open_session() | OpenedSession | POST /v3/sessions → session_id + console_url |
send_kickoff() | — (None) | append the user message that starts the turn |
poll_status() | RunOutcome | scan in-session events for a terminal status |
fetch_result() | ProviderResult | read final text + cumulative token usage |
parse_webhook() | ParsedWebhook | verify signature, map raw event → kind |
- OpenedSessionsession_id · console_url
- RunOutcometerminal · status · stop_reason · event_cursor
- ProviderResulttext · usage · status · stop_reason
- ParsedWebhookevent_id · session_id · kind · raw_event_type
Each agent_runs row carries a runtime column. The lifecycle reads it and calls get_provider(runtime), which resolves the name against the _PROVIDERS registry. DEFAULT_PROVIDER is "anthropic" today.
TransientProviderError (retryable, carries a retry_after_s) or PermanentProviderError (do not retry). The Runner above translates those into durable retry decisions — the runtime never knows about Inngest. Run lifecycle
a single run One standalone run as a sequence across the four lanes, time flowing down. Solid arrows are calls; dashed arrows are the async webhook and the SSE stream the client waits on. The signature move is the park: a multi-minute run holds no worker — the runtime parks on a durable wait and a signed webhook wakes the exact run by run_id. It plays on a loop; click any step to pin it.
poll_status on every wake, and a reaper sweeps runs past their deadline — so a run still converges even if a webhook never arrives. All terminal writes are status-gated, so a webhook and the reaper racing to close the same run resolve to one outcome. Claude managed agents
the provider today The one backend implemented today. Anthropic hosts the agent loop, the sandbox container, the tool catalog, and memory; our AnthropicProvider adapter turns that into the neutral contract. Two event namespaces matter, and the adapter bridges them.
Inside a session, an append-only event log carries the turns (agent.message, user.message, status events). poll_status() scans this log to classify whether the run has gone terminal — the safety-net check on every wake.
Out-of-band, Anthropic POSTs signed session webhooks. parse_webhook() verifies the signature, dedupes on event id, and maps the raw event to a neutral AgentWebhookEventKind. Only two kinds wake the parked run.
Every raw webhook maps to one neutral AgentWebhookEventKind, and the kind decides what the receiver does. Grouped by that effect — the run only wakes on two of them:
session.idledidlesession.terminatedterminated
session.run_startedrun_startedsession.rescheduledprogress
session.deleteddeletedthread.*thread_*vault.*vaultunknownunknown
provider_session_id and console_url, so any run cross-references straight to its Anthropic Console session for debugging. Custom tools that need our database or proprietary APIs run as remote MCP (HTTP / SSE); STDIO MCP is not supported. Models & economics
interactiveModel selection is per-agent config — switching does not require a code change. Cost is Claude API token rates plus a flat $0.08 per session-hour while the session is running; idle sessions do not bill runtime. Token rates per 1M tokens:
- 5m write$6.25
- 1h write$10.00
- cache read$0.50
- 5m write$3.75
- 1h write$6.00
- cache read$0.30
- 5m write$1.25
- 1h write$2.00
- cache read$0.10
All rates per 1M tokens.
A single Sonar-style company-research run takes ~90s wall-clock, ~60s of which the agent is actively running. It burns ~80k input and 6k output tokens on Sonnet 4.6: input $0.24, output $0.09, runtime ~$0.0013 — roughly $0.33 per run. The runtime surcharge is a rounding error against the tokens; it only bites for an agent that sits at running for hours.
The 50% batch discount and Fast Mode pricing on the bare Messages API do not apply inside managed agents. High-volume async batch work may still be cheaper against the Messages API directly. Web search is $10 per 1,000 searches.
Adding a second provider
three stepsBecause the seam is a protocol, adding a backend is a contained change — three steps, none of which touch a workflow.
Implement the protocol
Write a class that satisfies AgentProvider — the six async methods plus name and capabilities. Speak only the neutral shapes; translate your backend's wire format inside the adapter.
Register it
Add an instance to _PROVIDERS in providers/__init__.py, keyed by its name. get_provider(name) resolves a run's runtime value to your impl; DEFAULT_PROVIDER stays Anthropic.
Route runs to it
Set runtime on the agent (or the run) to your provider's name. The lifecycle reads that column and calls get_provider() — nothing in the composition layer above the seam changes.