Engineering · Guides · Agent runtime

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.

Atom agent_runtime · run one agentSeam AgentProvider interfaceToday Claude managed agentsDurability webhook park / wake
agent configruntime·provider seamhosted session
00

At a glance

AgencyCore 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.

1
Job at the core
run exactly one agent
1
Swap point
provider-neutral interface
0
Workers held
while a run is parked
6
Methods to add a backend
implement the protocol
KEYA config, a run, a pluggable backend

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.

01

System overview

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.

Front dooragent_runs — the one import surface
Run logicexecute exactly one run
Support roleswhat a run leans on
Provider seamthe backend, behind one interface
Idea 1 · an Inngest-free atom

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.

Idea 2 · pluggable backend

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.

02

Core primitives

Five words carry most conversations about the runtime. The first four describe what the backend exposes; the last is our own unit of work.

Agent

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.

Environment

A container template — base image, env vars, attached MCP servers, vault bindings. Resolved to a backend environment_id when a session opens.

Session

A running agent instance on the backend. Persistent filesystem, long-lived. The runtime holds its session_id and console_url on the run row.

Event

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.

Run

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.

03

The provider abstraction

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.

MethodReturns (neutral)Claude managed agents
resolve_ref()AgentReflist agents, match by name → (agent_id, version)
open_session()OpenedSessionPOST /v3/sessions → session_id + console_url
send_kickoff()— (None)append the user message that starts the turn
poll_status()RunOutcomescan in-session events for a terminal status
fetch_result()ProviderResultread final text + cumulative token usage
parse_webhook()ParsedWebhookverify signature, map raw event → kind
Neutral shapes · the only vocabulary both sides share
  • OpenedSessionsession_id · console_url
  • RunOutcometerminal · status · stop_reason · event_cursor
  • ProviderResulttext · usage · status · stop_reason
  • ParsedWebhookevent_id · session_id · kind · raw_event_type
Selection · one line, per run

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.

Errors cross the seam neutral too: a provider raises 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.
04

Run lifecycle

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.

ClientbrowserAPI & EdgeFastAPIRuntimeRunner + atomProviderClaudePOST /agents/runscreate_run → agent_runs rowstatus queued202 { run_id, queued }claim_run (CAS) → runningone winneropen_session + send_kickoffwait_for_event PARKEDno worker heldsession.idled → webhookwake by run_idpoll_status + fetch_result60s backstop reconfirmsbill once · mark terminalSSE: running · completed
Webhooks can be lost. The parked wait is bounded to 60-second slices and re-confirms with 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.
05

Claude managed agents

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.

In-session events · the poll path

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.

Webhook events · the wake path

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:

Wakes the parked run2
fires agent/session.ended → finalize
  • session.idledidle
  • session.terminatedterminated
Relays an SSE progress frame2
keeps the stream alive, run stays parked
  • session.run_startedrun_started
  • session.rescheduledprogress
Logged, no action4
observed for visibility only
  • session.deleteddeleted
  • thread.*thread_*
  • vault.*vault
  • unknownunknown
The run row keeps 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.
06

Models & economics

Model 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:

Opus 4.7heaviest reasoning
$5.00input
$25.00output
  • 5m write$6.25
  • 1h write$10.00
  • cache read$0.50
Sonnet 4.6daily driver
$3.00input
$15.00output
  • 5m write$3.75
  • 1h write$6.00
  • cache read$0.30
Haiku 4.5fast + cheap
$1.00input
$5.00output
  • 5m write$1.25
  • 1h write$2.00
  • cache read$0.10

All rates per 1M tokens.

Per-run cost calculatortoken rates + $0.08 / session-hour
Input$0.24
Output$0.09
Runtime$0.0013
Total / run$0.33
EGA worked example

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.

!No batch / Fast-Mode discounts inside managed agents

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.

07

Adding a second provider

Because the seam is a protocol, adding a backend is a contained change — three steps, none of which touch a workflow.

1

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.

2

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.

3

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.

08

Sources

AgencyCore · Engineering guide · Agent runtimeProvider-neutral · Claude managed agents today