Agent runtime
The agent runtime is a Python service (FastAPI and CrewAI) that turns a task and its context into a structured result; it talks only to model providers. This page is for whoever runs or integrates the runtime: its HTTP contract, how it picks a provider, what data leaves the process and how cost is controlled.
On this page
- Role and HTTP contract
- Simulation and live mode
- Model providers
- Provider order and fallback
- Data policy: ALLOWED_PROVIDERS
- What leaves the process and redaction
- Tools: the model asks, the backend executes
- Code sandbox
- Cost accounting and budgets
Role and HTTP contract#
The backend calls the runtime over HTTP with a task, the agent profile and the context it assembled. The runtime returns validated structured output and, if the agent wants to act, a list of tool_requests. It never executes a tool and never calls an external system: the model may be misled by untrusted text, but it can only propose.
When RUNTIME_TOKEN is set, the runtime requires Authorization: Bearer <token> on every route except GET /healthz, compared in constant time; without it, it answers 401. The endpoints are:
curl http://localhost:8000/healthz
# {"status":"ok","mode":"live","provider":"anthropic",
# "configured_providers":["anthropic","custom"],"default_provider":"anthropic"}
| Endpoint | Purpose |
|---|---|
POST /v1/plan, /v1/plan-phases, /v1/plan-phase, /v1/replan | Turn a request or a project into a plan of tasks for the available agents; replan replaces a failed node |
POST /v1/run-task | Run one task for one agent; returns output, consults, tool_requests and usage |
POST /v1/consult, /v1/review, /v1/synthesize, /v1/summarize | Agent-to-agent questions, quality review, final report and summaries |
POST /v1/counterargument | Advisory counterargument for a high-risk approval |
POST /v1/route, /v1/chat-reply | Chat routing and conversational replies |
POST /v1/estimate, /v1/context-sizes | Cost range for a plan and prompt size; neither calls the model |
POST /v1/provider-test | One small real call to test an organization's key; always answers 200 with a stable code |
GET /healthz | Status, mode (simulation or live), configured providers and default provider; never keys |
Warning
Set RUNTIME_TOKEN to the same value in the backend and the runtime. Without it, anyone who can reach the runtime port can spend your provider keys; the backend logs a warning at startup.
Simulation and live mode#
The HTTP contract is the same in both modes; only the engine changes.
- Simulation: on with
SIMULATION=trueor when no provider is configured. It returns plausible plans, results, tokens and costs with simulated latency (SIM_LATENCY_SCALE=0removes the delay), with no network and no cost, and it ignores the provider policy because no data leaves the process. - Live: the CrewAI engine calls the provider and validates the output against the
StructuredOutputschema.LLM_DIRECT_CALL=truereplaces the CrewAI loop with a single chat call. - Degraded: if a provider is configured but the live engine cannot start, the runtime answers in simulation and
GET /healthzreturnsstatus: degradedwith awarning.
Note
The optional provider and usage.failed_providers fields appear only when a real provider answers, so clients do not need to tell the modes apart.
Tip
SIMULATION=true always wins, even over an organization's own key: no network call is made.
Model providers#
There are five providers. One counts as configured when its key is in the environment (for custom, the base URL is enough) or when the request carries the organization's own key. A custom model is passed as openai/<name>, and a free local model can be priced at 0 with CUSTOM_PRICE_IN_PER_M and CUSTOM_PRICE_OUT_PER_M.
Organizations can bring their own key. It is sealed with AES-256-GCM under a key wrapped by CONNECTIONS_KEK, no API response returns it beyond its last four characters (key_last4), and it travels in clear only on the internal hop from the backend to the runtime.
| Provider | Configuration | Default model |
|---|---|---|
deepseek | DEEPSEEK_API_KEY, DEEPSEEK_MODEL | deepseek/deepseek-chat |
anthropic | ANTHROPIC_API_KEY, ANTHROPIC_MODEL | anthropic/claude-sonnet-5-5 |
openai | OPENAI_API_KEY, OPENAI_MODEL, optional OPENAI_BASE_URL | openai/gpt-4.1 |
kimi | KIMI_API_KEY, KIMI_MODEL, optional KIMI_BASE_URL | openai/kimi-k2-0905-preview (Moonshot, OpenAI-compatible) |
custom | CUSTOM_LLM_BASE_URL (required), CUSTOM_LLM_API_KEY, CUSTOM_LLM_MODEL | Whatever CUSTOM_LLM_MODEL names: any OpenAI-compatible endpoint, such as vLLM, Ollama or your own gateway |
Provider order and fallback#
For each call the runtime builds an attempt list. Each source adds only the providers not listed yet, so every configured and permitted provider ends up as a fallback:
MODEL_PROVIDER_ORDER_<ROLE>: the agentrolein capitals (for exampleMODEL_PROVIDER_ORDER_LEGAL) or the call kind:PLAN,REVIEW,CONSULT,SYNTHESIZE,SUMMARIZE,ROUTE,COUNTERARGUMENT.role_providersfrom the organization's model policy.- The request's
preferred_providers. - The global
MODEL_PROVIDER_ORDER(defaultdeepseek,anthropic,openai,kimi,custom).
# Runtime (agent-runtime)
SIMULATION=false # true: scripted engine, no network, no cost
DEEPSEEK_API_KEY=... # one configured provider is enough
ANTHROPIC_API_KEY=...
CUSTOM_LLM_BASE_URL=https://llm.internal.example/v1 # OpenAI-compatible
MODEL_PROVIDER_ORDER=anthropic,deepseek,custom
MODEL_PROVIDER_ORDER_LEGAL=anthropic # per role / call kind
ALLOWED_PROVIDERS=anthropic,custom # operator ceiling
RUNTIME_TOKEN=<shared secret> # same value in the backend
# Backend
RUNTIME_URL=http://agent-runtime:8000
RUNTIME_TOKEN=<shared secret>
BUDGET_USD=50 # organization budget (default 50)
REQUEST_BUDGET_CAP_USD=5 # default cap per request
AGENT_BUDGET_USD=20 # default monthly cap per agent
Note
A transient failure (timeout, rate limit, 5xx) is retried on the same provider PROVIDER_RETRIES times (default 1, waiting PROVIDER_RETRY_BACKOFF_S) before moving to the next; PROVIDER_TIMEOUT_S (default 120) bounds each attempt. The organization policy can override the model per role (role_models), but only for a provider that is already configured and allowed.
Data policy: ALLOWED_PROVIDERS#
The data policy is applied before anything is sent. The effective list is the operator's ALLOWED_PROVIDERS intersected with the request's allowed_providers: a provider outside the list is neither called nor used as a fallback, and a request can narrow the operator ceiling but never widen it.
The backend fills allowed_providers from each organization's model policy (GET/PUT /api/v1/settings/model-policy, owner and admin only, every change audited). When no provider is left it sends the sentinel value none, so the runtime refuses the call instead of widening the set. With byo_only, only the organization's own keys may answer. Failures are explicit:
{
"detail": {
"code": "no_allowed_provider",
"message": "No allowed model provider is configured for this request; nothing was sent.",
"providers_tried": [],
"allowed_providers": ["anthropic"],
"configured_providers": ["deepseek"]
}
}
| Code | HTTP | Meaning |
|---|---|---|
no_allowed_provider | 422 | No configured provider is allowed; nothing was sent |
all_providers_failed | 502 | Every allowed provider failed; providers_tried lists them |
provider_no_balance | 402 | The provider accounts tried have no balance left; retrying will not help |
call_budget_exceeded | 402 | The call's estimate is above max_cost_usd, the allowance the backend gave it; nothing was sent |
What leaves the process and redaction#
Only the prompts of each call reach a provider. The contract has no fields for credentials, connection_id, API URLs or tokens, so they cannot be sent. A prompt may contain:
- The user's request text and the agent's persona, responsibilities and tools.
- Outputs of dependency tasks, agent memory and external content.
- Tool results that the backend already filtered and redacted.
- Consult or synthesis questions.
Note
Secrets are redacted twice. The backend redacts tool results first; the runtime's redact.py module is the second barrier and covers private keys, JWTs, GitHub, AWS and Google tokens, API keys, bearer tokens, password-style assignments, URLs carrying tokens and Luhn-valid card numbers. It applies to model output and to provider error messages. External content, dependency outputs and memory go only in the user prompt, inside blocks marked as untrusted data with an instruction not to obey them; the system prompt is built only from what the backend supplies.
Tools: the model asks, the backend executes#
A tool request is a proposal. The backend checks the agent's tool list, the arguments, the policy engine and the autonomy level, and may create an approval for a person. Only then does it run the tool, with credentials from the encrypted vault that never leave the backend. The result goes back to the model redacted and delimited as untrusted data.
Capabilities that the provider manifest marks as irreversible, such as Gmail's email.send, always require a human decision, whatever the agent's autonomy. An approved action is claimed at most once, so a restart cannot run it twice. Tools without a real connection use in-memory fake adapters, which is how simulation works end to end.
{
"tool_requests": [
{ "tool": "email", "action": "send", "risk": "high",
"args": { "to": "customer@example.com", "subject": "Proposal", "body": "..." } }
]
}
Code sandbox#
The code.run action lets an agent ask to execute Python. It is available only when the backend has SANDBOX_URL and SANDBOX_TOKEN; without them it is denied with sandbox_unavailable. By default every run is an approval that shows the code, the input files and the purpose, and it is bound to the code hash and the SHA-256 of every input: changing either requires a new approval.
After approval the backend sends the run to a separate runner that starts a disposable container:
- No network (
--network none), a read-only root, a non-root user and all capabilities dropped. - Time, memory, process and output-size limits (
SANDBOX_MAX_RUNTIME_SECONDS,SANDBOX_MEMORY_MB,SANDBOX_PIDS,SANDBOX_MAX_OUTPUT_BYTES) and an allow-list of output file types. - The runner's environment is not forwarded, so no secrets reach the code.
- The kill switch, an agent pause or read-only mode abort a running job.
Note
Desk mode is opt-in and off by default: it lets an organization run, without approval, code that only uses the agent's own files, with lower caps on code size, inputs and runs per task.
Warning
The runner mounts the Docker socket, which is root-equivalent on that host. Run it on a dedicated machine with no published ports.
Cost accounting and budgets#
Prices live in one table, model_prices.json, in USD per million tokens, byte-identical in the backend and the runtime (a test in each service enforces it). A model missing from the table is billed at the table's default rate and logged once. PRICE_IN_PER_M and PRICE_OUT_PER_M force a rate for every model.
Before each call the backend reserves the maximum estimate against the request, the agent and the organization. Afterwards it records the greater of the runtime's reported cost and its own recalculation, so it does not depend on the runtime's accuracy. Spending is read from a ledger in Postgres and survives restarts.
| Control | Default | Effect when exceeded |
|---|---|---|
BUDGET_USD (organization) | 50 | The call is not made; raising a cap through the API does not resume it |
REQUEST_BUDGET_CAP_USD (per request; budget_cap_usd overrides it) | No cap | The request pauses with budget.exceeded; raise the cap to continue |
AGENT_BUDGET_USD (monthly, per agent) | No cap | The agent's calls pause; raise the cap to continue |
COST_CONFIRM_THRESHOLD_USD | 1.0 | A person confirms the estimated cost before the run starts |
MAX_PARALLEL_PER_ORG | 8 | The organization's calls wait in a queue |
Note
The price table is a budgeting reference, not what your own provider contract charges.