Documentation — Intelligence
Extend & embed

Intelligence

agentd reaches the model over one logical wire. The agentic ReAct loop — which runs only inside a child process, a turn worker or a subagent — sends it messages plus a scoped tool catalogue and gets back text and structured tool calls. That wire is named in AGENT_INTELLIGENCE (or --intelligence) and authenticated with AGENT_INTELLIGENCE_TOKEN (or --intelligence-token). For resilience the wire can list several endpoints (failover priority by order), each with its own credential, and the list + model are hot-swappable without a restart — but it stays one model-facing channel. That is the whole surface.

This is the intelligence wire — the model-facing channel. It is categorically not MCP. Tools come from MCP servers; this channel only carries the LLM request/response. Do not conflate the two.

One wire, many endpoints. AGENT_INTELLIGENCE takes an ordered list of endpoints for failover (see Resilience), and that list and the model are hot-swappable without a restart (see Runtime hot-swap). The single-endpoint behaviour described first is exactly the one-element-list case.

The one URI: HTTPS

Intelligence is reached over HTTPS — a hand-rolled HTTP/1.1 client over a Read + Write byte stream, so the intelligence path needs no url/ICU stack. The endpoint is a single transport, https://, with a loopback http:// carve-out for local development.

URI formTransportUse caseBuild
https://host[:port]/pathTCP + TLSdirect provider, or a gateway sidecar/servicefeature tls (default)
http://127.0.0.1[:port]/pathTCP, loopback onlya same-host dev gateway (LiteLLM, a local vLLM, your own proxy)core

The URI is validated at startup, before any side effect. A scheme that isn't https:// (or a loopback http://) exits 2 in milliseconds:

$ agentd --instruction 'hi' --intelligence ftp://x
agentd: intelligence endpoint must be https://host[:port][/path] (got: ftp://x)
$ echo $?
2

A non-loopback http:// is rejected — plaintext to a remote LLM would leak the prompt and the token. Terminate TLS + provider auth at a gateway if you don't want the key in the agentd process; agentd reaches that gateway over https://, or over http://127.0.0.1 when it is a same-host sidecar.

https:// — direct provider or gateway (feature tls)

export AGENT_INTELLIGENCE_TOKEN="$OPENAI_API_KEY"
agentd \
  --instruction 'summarize the open incidents' \
  --intelligence https://api.openai.com/v1/chat/completions \
  --model gpt-4o \
  --mcp incidents=https://intra/incidents/mcp

TLS is rustls with the ring provider and webpki-roots; SNI is the parsed host.

http://127.0.0.1 — a same-host dev gateway (loopback only)

agentd \
  --instruction ./task.md \
  --intelligence http://127.0.0.1:4000/v1/chat/completions \
  --model gpt-4o \
  --mcp fs=https://intra/fs/mcp

A loopback gateway (a sidecar in the same pod, a dev proxy) terminates TLS and provider auth; agentd talks plaintext HTTP to it over loopback only. Any other http:// host is a startup error.


The wire: OpenAI-compatible by default

agentd ships three in-binary adapters, selected with intelligence.dialect (--intelligence-dialect, AGENTD_INTELLIGENCE_DIALECT): openai (the default), anthropic, and bedrock. The bias is deliberate: few adapters, thin binary, push provider quirks to a gateway.

Canonical: openai-compatible POST /v1/chat/completions

This is what the loop emits and parses by default. It covers vLLM, Ollama, LM-Studio, OpenAI proper, and most hosted gateways, and gives the model first-class tools + tool_calls (native tool-calling). The request body the adapter builds, with one round of tool-calling in the transcript:

{
  "model": "gpt-4o",
  "max_tokens": 1024,
  "messages": [
    {"role": "system", "content": "…"},
    {"role": "user", "content": "read /etc/hosts"},
    {"role": "assistant", "content": null,
     "tool_calls": [{"id": "call_1", "type": "function",
       "function": {"name": "fs.read", "arguments": "{\"path\":\"/etc/hosts\"}"}}]},
    {"role": "tool", "tool_call_id": "call_1", "content": "127.0.0.1 localhost"}
  ],
  "tools": [
    {"type": "function", "function": {
      "name": "fs.read", "description": "Read a file",
      "parameters": { /* MCP inputSchema, verbatim */ }}}
  ],
  "tool_choice": "auto"
}

Auth header: Authorization: Bearer <token>. The key is optional — a local keyless vLLM/Ollama needs no token. Each tools[] entry's parameters is the MCP tools/list inputSchema passed through verbatim — discovery belongs to the MCP client, and this wire never rewrites a schema. The adapter reads back choices[0].message.content, choices[0].message.tool_calls[] (parsing each function.arguments string into a JSON object), finish_reason, and usage.{prompt_tokens,completion_tokens}.

anthropic POST /v1/messages

Selected with intelligence.dialect: anthropic. Headers are x-api-key: <token> + anthropic-version: 2023-06-01. The system prompt is extracted out-of-band into the top-level system field; tools map to Anthropic's {name, description, input_schema} (same input_schema key — passed through verbatim). Assistant tool calls serialize as tool_use content blocks; tool results as tool_result blocks. stop_reason normalises into the same finish reason, usage from usage.{input_tokens,output_tokens}.

bedrock — the Amazon Bedrock Converse wire

The one dialect that requires an auth block: intelligence.auth.kind must be aws, because every dial is SigV4-signed rather than bearer-authenticated (validation rejects the pair otherwise). The model id rides the URL path/model/{modelId}/converse — not the request body, so model: is the Bedrock model id or an inference-profile id/ARN. Tool-calling and system prompts map onto the Converse shapes. See Authentication for a complete block.

Anything else → push it to a gateway

Gemini, Cohere, and other providers are not in the binary. Run a gateway that exposes an OpenAI-compatible /chat/completions, point AGENT_INTELLIGENCE at it (https://, or a loopback http:// for dev), and the canonical adapter handles the rest. This keeps the binary thin and the provider matrix out of agentd's release cadence.


Native tool-calling

Native tool-calling is the only tool path. Every dial that has a non-empty catalogue carries it in the dialect's own field — tools + tool_choice:"auto" (openai), tools with input_schema (anthropic), toolConfig.tools[].toolSpec (bedrock) — and the loop reads the model's structured tool calls back out. There is no knob and no prompt-embedded action protocol: no catalogue rendered into the system prompt, no brace-matching over prose.

The consequence is a requirement, not a fallback. An endpoint that ignores tools never asks for one, so the turn ends on the model's first message. Put an OpenAI-compatible gateway that implements tool-calling in front of a model that lacks it.


Credentials

The credential is resolved per endpoint and never logged. Set it from env, from a flag, or from a config file through a {{secret:…}} reference (see Authentication for the full auth: block — OAuth, AWS SigV4, SPIFFE):

# flag (sets endpoint 1's credential)
agentd … --intelligence-token "$OPENAI_API_KEY"
# or env (preferred for 12-factor / secret mounts)
export AGENT_INTELLIGENCE_TOKEN="$OPENAI_API_KEY"
agentd …
# or read from a mounted file (rotation-friendly)
export AGENT_INTELLIGENCE_TOKEN_FILE=/var/run/secrets/llm/token
agentd …

Per-endpoint credentials

With a multi-endpoint list, each element resolves its own credential by position (1-indexed):

EndpointInline envFile env
1 (primary)AGENT_INTELLIGENCE_TOKEN (or --intelligence-token)AGENT_INTELLIGENCE_TOKEN_FILE (or --intelligence-token-file)
2AGENT_INTELLIGENCE_TOKEN_2AGENT_INTELLIGENCE_TOKEN_2_FILE
NAGENT_INTELLIGENCE_TOKEN_<N>AGENT_INTELLIGENCE_TOKEN_<N>_FILE

Precedence per endpoint: an explicit inline env override wins, then the …_FILE variant, then (endpoint 1 only) the resolved --intelligence-token. An endpoint with no token resolved is legal — a public/keyless gateway needs none. The list URI itself never carries a key.

Rules:

  • Never inline in a file. A config file may name the credential only through a {{secret:NAME}} / {{secret-file:PATH}} reference; a literal value there is a validation error (intelligence.token carries an inline credential). Env and flag values may be literal. Wherever it comes from, the resolved secret is never persisted and never put in the transcript fed back to the model.
  • Redacted everywhere. The Config Debug impl prints the token as ***; the secret-header allowlist keeps authorization / x-api-key out of the JSON-lines logs and any span; the endpoint-health telemetry shows transport + index only. There is a test asserting the raw value never appears.
  • Optional for keyless endpoints. A local vLLM/Ollama on a loopback http:// endpoint (dev) needs no token at all.
  • File rotation. A named-but-unset per-endpoint token file is caught at startup (exit 2) so a failover never discovers an unreadable secret. The …_FILE variants are read through the secret-file reader, the rotation-friendly path for k8s Secret mounts / Vault Agent sidecars.

Example of the redaction (the token is set but never echoed):

// proc.start — note: no token field exists anywhere in the log stream
{"ts":"2026-06-25T12:00:00Z","level":"info","event":"proc.start","run_id":"r-…",
 "agent_id":"sup","agent_path":"0","comp":"supervisor","pid":1,
 "version":"1.1.0","runtime":"1","instance":"agentd",
 "config_files":["settings.yaml"]}

How the call behaves

  • One connect per call, Connection: close — no keep-alive, no pooling. The request rate is single-digit per second per subagent, so this is free.
  • Synchronous and blocking for the subagent's turn — the agentic loop is single-threaded per subagent. The supervisor never blocks on the LLM call.
  • Non-streaming (stream:false). A timeout surfaces as a transient transport error and is retried with bounded backoff.
  • HTTP status taxonomy — which status is retried and which is fatal:
    • 429 / 5xx → bounded retry with backoff + jitter.
    • 401 / 403 → fatal auth → exit 4.
    • connection refused/reset → fatal intelligence-unreachable → exit 4.
    • a named-but-unset key is caught at startup → exit 2 (validate first, don't burn a round-trip on a 401).

Model tiers

The model used to be one instance-global string. Choosing a cheap model for a classify step and a frontier one for a judgement call meant forking a subagent process purely to change it — model appeared in no node's field list, so writing it on a step was exit 2. The breaker is per endpoint and is not operator-tunable — three consecutive failures open it, and the cooldown starts at 5s and doubles to a 60s cap — so a frontier and a cheap model behind one gateway still share one breaker; what a tier separates is the wire model and the context window, not the failure accounting.

intelligence.models names each tier once:

services:
  frontier:
    kind: intelligence
    endpoint: https://api.example.com/v1
    auth: {kind: oauth2, issuer: "https://id.example.com", grant: client_credentials}
    rate: "60/1m"

intelligence:
  models:
    big:   {model: big-model-1,   window: 200000, fallback: small}
    small: {model: small-model-3, window: 128000}
  default: big
  preflight_model: small

context: {summarize: {model: small}}

workflows:
  - name: triage
    steps:
      sort:   {kind: classify, model: small, input: "…", classes: [a, b]}
      decide: {kind: agent,    model: big,   instruction: "…"}

model is accepted on every kind that makes a model call: think, agent, and the five shaping presets — classify, extract, summarize, judge, route. The presets are the point, not an afterthought: they are the cheap high-volume steps, and a catalogue that could not reach them left the only lever on the whole instance.

A tier is not a second service catalogue, and it never was: its whole vocabulary is model, window and fallback. A tier writes no endpoint, no auth and no tags of its own, so "make it cheaper" cannot quietly mint a new endpoint or credential. Trifecta tags are not part of this either — tags is kind: mcp vocabulary, and writing it on a kind: intelligence entry is exit 2.

Two keys that used to live here are gone as of 1.15, because neither did anything. service: named a kind: intelligence catalogue entry, was checked at startup, and was then ignored — every tier's call went to intelligence.endpoints regardless, so a config reading "this tier talks to my on-prem gateway" talked to the shared one. pricing: parsed and validated and was never multiplied by anything. Both are refused by name now rather than accepted and inert; see Cost for what replaced the second one, which is nothing.

Every tier's call goes to the endpoint list intelligence.endpoints names — the ordered failover list below — because that list, and nothing else, is what the client is built from at startup and rebuilt from on reload; a tier contributes the wire model name and, when it declares one, the window. That is the whole reason service: was removed: it looked like it selected an endpoint and did not, and there is no honest way to document a routing key that does not route. security.egress: closed works the same way from the other side: it walks the four outbound surfaces — mcp.servers, intelligence.endpoints, a2a.peers, and the HTTP dials (store.http, a workflow url: reference, a literal http step URL) — and demands each URL match a catalogue entry of its own kind on scheme, authority and a path that extends the entry's on a segment boundary. The endpoint is what gets admitted; a tier naming a catalogued entry admits nothing on its own.

A tier's declared window also replaces the compaction threshold's guess from the model name, which is a substring match and simply wrong for any provider whose naming does not happen to match.

References resolve to the wire model at the edges, so a tier name never reaches a provider, and every reference is checked at startup: an unknown tier on a step, in default, in preflight_model or in summarize.model is exit 2 rather than a run quietly asking for a model called smal. A fallback cycle is refused too — a degradation ladder that loops is a hang under exactly the conditions it exists to survive.

turn.model puts the resolved model on the log line, because "how much did that cost, and on what" now has a per-turn answer.

<a id="cost-recorded-not-spent"></a> The "on what" is the half agentd answers, and only that half. There is no cost accounting: intelligence.pricing and a tier's pricing: were removed in 1.15 because nothing ever read them. They parsed, validated, published a shape into the JSON schema so editors could autocomplete them — and then the trail ended. Nothing multiplied a rate by a token count, no event carried a money figure, and every limit under intelligence.budget is denominated in tokens and requests rather than currency.

Both spellings are refused by name now. A config field that accepts a number and does nothing with it is worse than an absent one, because it reads like a feature: an operator writes their rate card into the config and reasonably expects a spend figure to come back out.

If you want money, take it from the token counts: turn.model puts the resolved model on the log line and the usage events carry the token totals, so a rate card applied downstream — in your metrics pipeline, against agent_tokens_total by model — gives you a figure agentd can stand behind. Real in-process cost accounting is a feature worth designing rather than half declaring: which tokens are cached, how a tier rollup composes, what a budget denominated in currency does when a provider changes its prices mid-run.

Resilience: multi-endpoint failover & the circuit breaker

AGENT_INTELLIGENCE (or --intelligence) accepts an ordered, comma-separated list of endpoints. List order is failover priority — the first element is the primary. A single-element list is exactly the single-endpoint behaviour above; the failover/breaker machinery is inert with one endpoint.

# a primary provider, a second region, then a loopback sidecar as last resort
agentd \
  --intelligence 'https://gw-a.example/v1,https://gw-b.example/v1,http://127.0.0.1:4000/v1' \
  --model claude-opus-4 \
  …

Every element is an https:// endpoint (or a loopback http:// sidecar), and each resolves its own credential (see Credentials).

The failover sweep (sticky-primary)

Each logical complete call wraps one bounded sweep over the list:

  • Try the active endpoint. On a failover-class error — connection refused/reset, timeout, HTTP 5xx, or 429 — advance to the next available endpoint in list order.
  • A non-failover error is returned immediately, with no failover: 401/403 auth, other 4xx, or a malformed body are the same on every endpoint, so trying the next one only wastes a round-trip. (An auth failure on every endpoint is a misconfig → exit 4, never an endless backoff loop.)
  • On success, snap active back to the lowest-index healthy endpoint (sticky-primary), so a fallback is temporary by construction — once the primary recovers, the next call returns to it.

The wire/adapter/JSON path is unchanged; only endpoint selection wraps it. Each attempt still dials fresh (Connection: close).

The per-endpoint circuit breaker

Every endpoint carries its own three-state breaker, decided synchronously against the wall clock when the endpoint is consulted — no prober thread, no background timer:

StateMeaning
closedNormal, in rotation.
openRemoved from rotation for a cooldown after 3 consecutive failover-class failures.
half-openAfter the cooldown elapses the next consult promotes it to half-open: it is eligible for exactly one probe — success re-closes it, failure re-opens it with a longer cooldown.

The cooldown starts at 5s and doubles on each consecutive open up to a 60s cap. While an endpoint's breaker is open-and-cooling it is skipped entirely (no failover advance is even recorded for it). When every endpoint is open-and-cooling, the list is "all down": on a once run that surfaces as exit 4; a long-lived daemon backs off and keeps serving (it does not crash on a transient roll).

These transitions feed the metrics (agent_intel_up, agent_intel_errors_total{reason}) and the intel.* events — see Observability.

Endpoint health — the failover snapshot

Each subagent keeps a live view of every intelligence endpoint's health. It is what the failover sweep and the breakers read, and it never holds a URL, cid, or credential — a health snapshot is read by operators and shipped in logs, so it carries only the bounded structural transport + addr and the live counters:

{
  "active": 0,
  "all_down": false,
  "model": "claude-opus-4",
  "endpoints": [
    { "index": 0, "transport": "https", "addr": "gw-a.example", "state": "closed",
      "active": true, "ewma_latency_ms": 41, "error_rate": 0.0, "consec_fail": 0,
      "last_ok_ms_ago": 120 },
    { "index": 1, "transport": "https", "addr": "gw-b.example", "state": "open",
      "active": false, "ewma_latency_ms": 0, "error_rate": 1.0, "consec_fail": 3,
      "opened_ms_ago": 800, "cooldown_ms": 5000, "last_err": "refused" }
  ]
}

The addr is the bounded structural address (host[:port] with the path dropped) — enough to tell endpoints apart, never a secret.

What leaves the process is the summary, not the snapshot. The supervisor has no LLM of its own, so a child reports transport + index only on entering and leaving all-down; the supervisor latches that into agent_intel_all_down, the intel.health event, and /readyz (which flips to 503 while every endpoint is open). Per-call outcomes drive agent_intel_up, agent_intel_errors_total{reason}, agent_intel_calls_total, and agent_intel_call_duration_ms — see Observability.


Runtime hot-swap (--model-swap)

The intelligence endpoint list and the model are reloadable — a hot reload (SIGHUP, or a watched config-file change; see Configuration) that changes intelligence.endpoints or intelligence.model swaps the model live, with no restart:

  • New spawns use the new config immediately (the spawn template is repointed).
  • In-flight runs — turn workers and subagents already running — receive a control frame and apply it at the next turn boundary. An in-flight model call (complete_once) is never torn, and the conversation transcript is continuous (no context reset).

A repoint that changes only the endpoint list (model unchanged) is always invisible — the run rebuilds its client with fresh breaker state (so no stale breaker carries to a new endpoint) and continues. The endpoint URL and credential travel on the control frame like the spawn payload and are never logged.

--model-swap (env AGENT_MODEL_SWAP) controls only what happens when a reload changes the model under an in-flight turn:

PolicyBehaviour
finish-on-old (default)The turn in flight when the reload lands completes on the old model; the next turn uses the new model over the full existing transcript. Cheapest — no wasted work.
restart-turnThe in-flight turn still finishes (the model call is never torn), but its result is discarded and the turn re-runs on the new model from the same pre-turn transcript. Costs one turn, bounded by the step budget.

A swap is audited with the intel.swap event (kind model or endpoint, the model names, the policy, and whether the endpoint list changed — never a token or URL).

A ConfigMap-driven roll is the canonical trigger: mount the config file from a ConfigMap, run with --watch-config (needs --config + --features config-watch), and a ConfigMap update reloads the endpoint list/model live. The intelligence endpoint identity is reloadable via the config-file schema (intelligence.endpoints / .model / .swap_policy); the credential in that file is a {{secret:…}} reference, so rolling the ConfigMap never moves a literal secret.


The real flag/env surface

These are the flags and env vars the binary accepts. (Env name in parentheses; the flag wins over env, which wins over the default.)

FlagEnvMeaning
--intelligence <URI[,URI…]>AGENT_INTELLIGENCEthe endpoint list: comma-separated https:// (or a loopback http://), order = failover priority (required)
--intelligence-token <T>AGENT_INTELLIGENCE_TOKENendpoint-1 bearer / x-api-key value (never logged)
--intelligence-token-file <PATH>AGENT_INTELLIGENCE_TOKEN_FILEread endpoint-1's token from a mounted file (rotation)
(per-endpoint, env-only)AGENT_INTELLIGENCE_TOKEN_<N> / …_<N>_FILEendpoint N's token / token-file (1-indexed, N ≥ 2)
--model <NAME>AGENT_MODELmodel id sent in the request body (reloadable)
--model-swap <POLICY>AGENT_MODEL_SWAPin-flight model-swap policy: finish-on-old (default) | restart-turn
--intelligence-dialect <D>AGENTD_INTELLIGENCE_DIALECTwire dialect: openai (default) | anthropic | bedrock
--max-tokens <N>AGENT_MAX_TOKENStoken budget for the run (default 2000000)
--deadline <dur>AGENT_DEADLINEwall-clock deadline, e.g. 600s, 5m (default 3600s)

Every one of these is a config_version: "1" document path as well — intelligence.endpoints, intelligence.token, intelligence.model, intelligence.swap_policy, intelligence.dialect, limits.run.tokens, limits.run.deadline — settable from a file, from AGENTD_<PATH>, or from --<path>; the flags above are the short spellings. Durations accept ms, s, m, h, or a bare integer (seconds). The endpoint list and model are reloadable (see Configuration and the hot-swap section); a token in a file must be a {{secret:…}} reference.


See also

  • Configuration reference (the full flag/env surface + the reloadable config file)
  • Authentication (the auth: block — OAuth, AWS SigV4, SPIFFE — on this endpoint)
  • Observability (the intel.* events, agent_intel_* metrics, the breaker signals)
  • Deployment and Scaling (multiple daemon replicas coordinate through the durable store)
  • MCP (where the tools on this wire come from)
  • The agent loop (who calls complete, and how a turn ends)
  • Security (SSRF, header injection, secret handling)