Explanation / Agen Architecture
Start here
How-to guides
Explanation
Agen Architecture
Status: describes the current implementation.
1. Overview
Agen is one agent engine used two ways:
- Embedded: an application links the engine through an SDK (Python, Node, Go) and owns the agent’s lifecycle. No platform needed.
- Managed: the Agen platform deploys, scales, wakes and supervises agents
across one machine or many. Every managed agent runs the same engine inside
the
agen-hostprocess.
clients: CLI · Web UI · REST · MCP
│
┌─────────▼─────────┐ ┌────────────┐
│ Hub │◄──────►│ Store │ SQLite (local)
│ control plane │ │ │ Postgres (distributed)
└─────────▲─────────┘ └─────▲──────┘
assignments ↓ │ ↑ status/heartbeat │ sessions, runs, spans
┌─────────┴─────────┐ │
machine │ Nest │ │
│ ├ Manager ───────┼── spawns ──► agen-host (Rust engine) ×N
│ └ Gateway ◄──────┼── A2A ─────► agen-host
└───────────────────┘
2. Components and languages
| Component | Language | Role |
|---|---|---|
agen-engine |
Rust crate | The agent: loop, providers, tools, MCP client, skills, permissions, secrets, memory, sessions, tracing, storage port. |
agen-host |
Rust binary | Runs one agent instance from a bundle. Standalone (agen-host run ./bundle) or managed (control + A2A HTTP server). |
agen-sdk-core |
Rust crate | Language-neutral SDK facade: JSON agent spec, run events and host tool/approval requests over one dispatcher thread; cancel keys. Every binding goes through it. |
agen-ffi |
Rust crate | C ABI over agen-sdk-core, used by the Go SDK. |
| SDKs | Rust (PyO3, napi-rs), Go (cgo) | Thin wrappers over agen-sdk-core; no agent logic of their own. |
| Hub | Go | Control plane: API, desired state, scheduler, autoscaler, triggers, task queue, registry, fleet view, identity issuance. |
| Nest | Go | One Manager + one Gateway + a backend: native (child processes) or Kubernetes (one pod per instance). Docker is a planned backend. |
| Manager | Go | Sole execution authority inside its Nest. Pulls assignments from the Hub, starts/stops agen-host instances, dispatches tasks, reports status. |
| Gateway | Go | Stable A2A address for agents in the Nest; load-balances across instances; wakes sleeping deployments (activator). |
CLI agen |
Go | Client of the Hub API. Also runs all-in-one mode (agen up). |
| Web UI | TypeScript + React | Client of the Hub API, served by the Hub at /. |
Local and distributed use the same binaries. Local all-in-one (agen up) runs
the Hub and one Nest in a single agen process with SQLite. In a distributed
fleet each Nest is its own agen nest run process; Agen does not restart it
itself, so run it under a service manager (systemd, or the StatefulSet in
deploy/kube).
3. Authority split
- Hub decides: stores desired state, schedules, autoscales, fires triggers, queues tasks, issues identities. It never executes agents and is never on the agent data path.
- Manager executes: only a Nest’s own Manager starts or stops processes in that Nest. Managers pull assignments; the Hub never connects into a Nest.
- Agents talk directly: A2A over HTTP, through the target Nest’s Gateway.
If the Hub is down, Managers keep their last assignments and A2A keeps working. Scheduling, scaling, triggers and the fleet view pause until it returns.
4. Resource model
| Resource | Meaning |
|---|---|
| Definition | An immutable, content-addressed bundle version: name@sha256:…. Stored in the Store. |
| Deployment | Desired state: definition digest, kind, scale policy, placement, budget, triggers, namespace. |
| Instance | One agen-host process in one Nest, running one Deployment. |
| Task | A durable unit of work queued for a Deployment. |
| Nest | An enrolled execution environment with capacity and labels. |
Deployment kinds:
| Kind | Instances | Behaviour |
|---|---|---|
singleton |
0 or 1 | One identity with its own session. Sleep = scale to 0; wake = scale to 1. |
pool |
min..max | Interchangeable workers sharing a task queue. |
task |
one per task | Instance is created for one task and exits when it finishes (the autoscaler starts up to max at once for queued tasks). |
Scale policy: {min, max, target_queue_per_instance, idle_timeout, max_concurrency, reserve}.
A deployment can be paused (agen stop, PauseDeployment): its instances
drain and stop, the autoscaler and wake-ups leave it at 0, queued tasks and
A2A calls wait (calls get 503), and scaling it is refused until it is resumed
(agen start, back to min).
5. Control flows
Deploy: client → Hub.CreateDeployment → Hub stores Definition + Deployment →
scheduler writes Assignments (deployment, nest, count) → Manager’s
WatchAssignments stream delivers them → Manager starts instances → instances
become ready → Manager reports via ReportStatus.
Scale: Hub.ScaleDeployment (manual) or autoscaler updates desired count →
scheduler adjusts Assignments → Managers start instances, or drain then stop them.
Backends: the Manager logic is the same on every backend; only how an instance (one agen-host) is started differs.
- native: a child process on localhost, tied to the Manager’s lifetime (Job
Object / Pdeathsig).
--host-memory-limitcaps each host’s memory (a Windows Job Object process limit, LinuxRLIMIT_AS). A limit that cannot be applied fails the start.- CPU is not limited, and hosts run as the Nest’s OS user with no further sandbox, so native Nests are for trusted agents. Use the Kubernetes backend (pod CPU/memory limits, seccomp, separate pods) to isolate agents from each other.
- kubernetes (
agen nest run --backend kubernetes, the Nest runs in a pod with deploy/kube/nest.yaml, in a namespace of its own):- Each instance is a pod in the Nest’s namespace (image
--kube-image,restartPolicy: Never, non-root, seccomp RuntimeDefault, CPU/memory limits, no service-account token). The Manager replaces a failed pod as it replaces a crashed process. - The definition is published as an immutable ConfigMap per digest and Nest pod (up to 700 KiB), mounted read-only.
- The host’s settings (Store DSN, ManagerService and host tokens, and the
variables of
--host-env/--host-env-dir, e.g. provider keys) go in a per-instance Secret, never in the pod spec. - Pods, Secrets and ConfigMaps are owned by the Nest pod, so Kubernetes removes them when it goes. A restarted Nest (StatefulSet, enrolment on its volume) deletes any pods left under its Nest id, then starts fresh ones. If the API server is unreachable while stopping, the Manager gives up waiting after 90 s.
- The Nest may create pods, so it gets a namespace without other secrets (the Hub’s admin token lives in the Hub’s namespace).
- Each instance is a pod in the Nest’s namespace (image
- On every backend, each instance requires a per-instance host token
(
AGEN_HOST_TOKEN, generated by its Manager) on its HostService API, so nothing but its Manager can drive it. On Kubernetes a NetworkPolicy adds network isolation where the CNI enforces it.
Autoscaler (runs on the Hub leader, every tick):
want = clamp(ceil(queued_and_running_tasks / target_queue_per_instance), min, max)
(target defaults to 1). reserve (2.46, part) raises that floor to
urgent_or_high_load + reserve, still capped by max, whenever any
urgent/high priority task is queued or running — so one arriving later
finds an instance already idle instead of waiting on a scale-up; with no
such load right now, reserve has no effect at all. Scale up to want at once; scale down to want only
after no task or A2A activity for idle_timeout (default 5 min for pools and
singletons, 0 for task deployments, which therefore stop as soon as their
queue is empty). A manual scale counts as activity, so it holds for one idle
window. A2A calls are load the queue does not show: a Gateway whose callers wait
on busy instances asks the Hub (ReportActivity with saturated) to raise
desired to one more than the live instances, up to max. A deployment whose
spend today (UTC) reached budget.max_usd_per_day is held at 0: no leases,
no wake-ups or manual scale-ups (callers get 503 with the reset time); runs in
flight finish (within the Manager’s drain timeout, 5 min by default; a longer
run is stopped and its task requeued) and queued work waits for the next day.
The check is made when work starts, so spend can overshoot by the runs
already in flight (bounded by max_usd_per_run × concurrency); a run’s cost
counts on the UTC day it started. A Gateway reports saturation only when this
Nest’s ready instances are all full (not while one is starting). task
deployments (idle timeout 0) are for queued tasks, not A2A traffic.
Capacity pressure: when a deployment wants more instances than are placed and
a Nest it may run on is full, deployments holding instances on that Nest that
have had no work for 1 min (PressureIdle) scale down without waiting out
their idle timeout, so the waiting work gets the room. A deployment whose
provider account is held (§8) does not scale up for queued tasks until the
hold’s retry time.
Wake on call: A2A request hits a Gateway for a deployment with 0 ready
instances → Gateway holds the request and calls Hub.RequestWake → desired ≥ 1 →
instance starts → Gateway forwards the held request. A call waits up to
WakeTimeout (10 min) for a free instance, whether the deployment is asleep,
starting, busy or waiting for Nest capacity; then it gets 503 with a retry
hint. While a call runs, the Gateway reports activity every 15 s, so a call
that outlasts the idle timeout does not see its deployment scaled to zero (and
its assignment, which its instance needs to delegate, removed) under it.
Tasks: Hub.SubmitTask stores a task (queued). Managers lease tasks for
their deployments (SELECT … FOR UPDATE SKIP LOCKED on Postgres; a single
writer on SQLite), dispatch to an instance, and report the result. Leases expire,
so tasks held by a dead Nest go back to queued. Every lease gets a new
lease_id fencing token; ExtendLease and CompleteTask must present the
current one, so a partitioned Nest cannot complete a task that was re-leased.
A task may carry a conversation key (scoped by its source: api:,
webhook:<trigger>:; A2A uses a2a:<caller>:<contextId>) and labels. Tasks
of one deployment with the same key share a session and so continue one
conversation (a singleton keeps its one conversation and ignores keys); a task is leased only when no older task with its key is queued
or in flight, so they run one at a time, in submission order. A key read back
from a task (already carrying its source prefix) is accepted as is. Labels are
copied to the task’s runs, tool calls and child tasks. A task may carry
not_before (at most 90 days ahead): until then it is not leased, does not
count as load for the autoscaler and does not hold up its conversation, though
it counts towards maxQueuedTasks. It may carry not_after: the leader’s
scheduler tick fails a still-queued task past it (ExpireOverdueTasks,
reason expired) instead of leaving it queued indefinitely. It may carry
schedule_key: SubmitTask cancels, in the same transaction as the insert,
the queued task of that deployment that already has the key, so at most one
is ever queued per key. It may carry priority (urgent, high, normal,
background; default normal): LeaseTasks orders a deployment’s own
queue by it before submission order, never across deployments and after a
conversation’s own ordering. It may carry locks (3.11, part): names it
must hold all of at once before LeaseTasks leases it (so no deadlock
from taking them one at a time), granted for as long as its lease and
refreshed when the lease is extended, released when it ends
(task_locks is what a task declared, locks what is currently
granted, one task per namespace and name); a task waiting on one reports
wait_reason: "locks". The built-in acquire_lock(name, ttl_seconds)/
release_lock(name) tools (1.5) grant or drop a lock named mid-run on
top of a task’s declared ones, through the same locks table and the
same uniqueness rule, so two runs can never hold the same resource
whether its lock was declared or named on the fly; released locks (by
release_lock, or when the task ends) can be re-acquired by another
task at once. Every grant — declared or mid-run — carries a fencing
token, name -> fence in _meta agen/locks: a separate, durable
counter (lock_fences) that advances only on a genuine handover to a
different task, never a re-grant or lease extension to the same one,
and keeps climbing across a release/re-acquire cycle rather than
resetting (it lives apart from locks, which taskFinished deletes
unconditionally) — this is the one thing an expired lease alone can’t
give a tool server outside the Hub’s own control: it can remember the
highest token it has seen for a resource and reject a write tagged with
a stale one. A task may be in state
waiting (3.3): it holds no instance or lease and is not autoscaler load,
and carries wait_for ("<kind>:<ref>", e.g. "child:<task id>")
describing what it is waiting for — unlike wait_reason, which says why a
still-queued task has not started, wait_for is set only while suspended
and cleared on resume. run_inbox holds what has happened for a waiting
task while it waits; writing a row whose kind and ref match a waiting
task’s wait_for requeues it at urgent priority without counting an
attempt (Store.WriteInbox), and a child task finishing always writes its
outcome into its parent’s inbox. A tool call suspends a run by returning a
ToolError carrying suspend instead of a result: the engine does not
write a tool message for it (it stays pending, exactly as if the process
had died there) and RunTask returns early with wait_for set instead of
an outcome. The Manager then calls the Hub’s SuspendTask (giving up the
lease, Store.SuspendTask) instead of CompleteTask, freeing the instance
for other work. Woken, the same task id is leased and dispatched again; the
engine resumes from its last checkpoint, finds the same call still pending
and retries it from scratch (not from a cached result) — which is why
call_agent’s unified path (below) checks the child task’s own status
again on resume, rather than trusting the run_inbox payload directly.
It may carry model_tier, the cost tier
for a routing model (§8). When the deployment has spent 80% of its daily
budget, leased tasks carry budget_pressure, and a routing model then uses
the bundle’s router.budgetTier; a background-priority candidate is
released right back under the same pressure (2.46, part — the same
mechanism an exhausted label’s candidate already uses), so whatever
budget is left goes to higher-priority work instead. When the Hub fails a
task (lease expired too often) or cancels it, it also ends the task’s
unfinished run and bumps its epoch, so the conversation is not left blocked
and a host still running it is fenced off. Deleting a deployment cancels its
unfinished tasks and ends every unfinished run (task or A2A) the same way.
The Hub logs instance starts,
failures and exits (from Nest reports) and task releases and lease expiries
on the deployment’s log.
Task overrides: a task may carry overrides (TaskOverrides: tools,
skills, systemAddendum, permissions (deny/ask only), budget), which
can only narrow what its bundle allows, never widen it. SubmitTask and
SubmitChildTask both validate it (an allow permission rule, or an
unparsable budget.maxDuration, is refused) and store it on the task as
JSON. A child task’s effective overrides are its parent’s combined with its
own (store.CombineOverrides): tools and skills allowlists intersect,
permissions concatenate, systemAddendum appends, and budget fields
take the smaller of each side — so restrictions accumulate down a tree and
a descendant can never regain what an ancestor gave up. The engine applies
them per run (never by mutating the shared AgentConfig, since one agent
process can run several tasks concurrently): a tool outside the tools
allowlist is hidden from the model outright (stronger than a plain deny),
permissions rules are merged in ahead of the bundle’s own, skills adds
to the bundle’s own preloadSkills, systemAddendum is appended last to
the system prompt, and budget narrows maxTurns, maxRunDuration,
maxTokensPerRun and maxUsdPerRun (a bundle’s own, lower limit is never
widened by a looser override). start_agent can set overrides only on a
delegate whose x-agen/config.json delegates[].overrides is true; the
calling engine enforces this before the request reaches the Hub.
Tree budgets (3.5, part): a root task’s own overrides.budget is
also the whole tree’s budget — not a separate field. When a child is
submitted (SubmitChildTask, or a header-linked task via
linkToCaller), Hub.narrowForTreeBudget finds the root (via
RootTaskID), reads its overrides.budget, and — if it set
maxTokens/maxUsd — computes the tree’s remainder (that limit minus
Store.TreeUsage’s sum of every run of every task of the tree so far,
joined through each run’s task_id) and narrows the child’s own
combined budget down to the smaller of its own request and that
remainder, through the exact same budget field and narrowing semantics
described above. No new engine code, proto field, or table: the existing
per-run budget_exceeded check (unchanged) is what actually enforces
whatever this narrows a child’s budget down to, the same as it always
has for a plain per-run override. A remainder already used up floors at
a small positive value (1 token / $0.0001) rather than 0, since 0 reads
as “unset, no limit” to the existing narrowing logic; the floor still
lets a child’s own first turn run (no usage yet is not over a 1-token
limit), so it is cut off on its very next check instead — the same
“overshoot of at most one model call” the deployment’s own daily budget
already documents and accepts.
narrowForTreeBudget alone is the “napkin math” slice, computed once
at each child’s own submission: a sibling spending heavily while
another is already running would not, on its own, retroactively shrink
the running one’s already-fixed budget, only the next child submitted
after it. Closed (3.5, the last gap, 2026-11-05): Hub.treeBudgetExceeded
mirrors narrowForTreeBudget’s own read (the root’s overrides.budget,
Store.TreeUsage) but as a live, per-turn check instead — true once the
tree’s current usage reaches the root’s limit, checked by the engine’s
turn loop via TaskStarter::tree_budget_exceeded, the exact same
ManagerService/NestService round trip is_held/unfinished_children
already use (turned out to need no new mechanism at all once actually
designed, not comparable to join/hold in size as first assessed). A true
result ends the run exactly the way the existing per-run budget_exceeded
check already does — a graceful partial finish with a report, through
failure_reason’s new "tree_budget_exceeded" → "tree_budget"
mapping — never a hard stop or a suspend. A failed check (Manager
unreachable) never stops or suspends the run on its own, same as every
other TaskStarter check.
Budget visibility (2.19/2.21, additive — not part of enforcement
itself): Hub.treeBudgetThresholds/labelBudgetThresholds fire the same
budget.threshold event (50/80/100%, deduped) afterTaskEnded already
fires for the deployment-wide daily budget, scoped "tree"/"label"
instead. GetMetrics’s DeploymentMetrics.label_budgets rolls up
today’s spend against every configured labelUsdPerDay rule regardless
of the call’s own time window, since a label budget resets daily, not by
buckets; a tree’s own rolled-up cost already existed via
ListRuns(rootsOnly=true)’s Run.TreeUsage. The engine appends a late
system-prompt line (after the cached prefix, so the provider’s prompt
cache over the rest of it stays valid) past 50% of any limit that
applies — it has no notion of whether that limit came from the bundle, a
task override, or this section’s own tree narrowing, since by the time
it checks it only ever sees one already-effective number, the same one
budget_exceeded/max_turns/max_run_duration check. Usage also now
carries cached_input_tokens (OpenRouter’s
prompt_tokens_details.cached_tokens); Usage::budget_tokens(weight)
counts it at budget.cachedInputWeight (default 0.1) instead of full
price for maxTokensPerRun — not persisted across a resume, which can
only overcount a resumed run’s spend, never undercount it.
Label budgets (3.5, part), scoped down from the plan’s own
labelBudgets namespace-config wording to a per-deployment rule set,
since there is no namespace-level budget config surface today (budgets
are otherwise deployment-scoped) and building one is a separate, larger
piece of product surface than this needed: Budget.label_usd_per_day
("key=value" → maxUsdPerDay, validated the same way a real task label
is — checkBudget, store.CheckLabels). Unlike max_usd_per_day
(which holds the whole deployment at zero instances once exhausted —
see §5’s autoscaler), an exhausted label only excludes the candidates
that carry it:
Hub.exhaustedLabels computes which configured rules have hit their
Store.LabelSpentToday sum (bounded-fetch-then-filter-in-Go over today’s
runs, the same shape ListEvents’s own label filter already uses, since
labels are JSON and this stays cross-dialect), and NestAPI.LeaseTasks
releases a leased candidate carrying one right back
(Store.ReleaseTask(notStarted=true)) rather than excluding it before
the lease — deliberately avoiding any change to Store.LeaseTasks’s own
SQL or signature (32+ existing call sites). A task waiting on an
exhausted label reports wait_reason: "label_budget" (cached per
deployment per response, in the same deploymentWait struct the
deployment-wide reason already uses) — distinct from "budget", since
only its own work is held back, not the deployment’s.
Hub leader: several Hub replicas can serve the API; exactly one is leader
and runs the scheduler, autoscaler and triggers. Leadership is a Store row
(name='hub-leader', holder, epoch, expires_at) renewed every few seconds.
Taking over an expired lease increments epoch; assignment and trigger writes
happen in a transaction that checks the writer’s epoch, so a stale leader’s
writes are rejected.
Triggers: owned by the Hub leader. Cron and webhooks create Tasks, so a
trigger that fires while no Nest is available still waits durably in the queue.
Webhooks arrive at POST /hooks/<ns>/<deployment>/<trigger> on the Hub with a
per-trigger secret. Missed cron windows (Hub down) are recorded as missed
trigger events (ListTriggerEvents), never dropped silently.
Cron schedules are standard 5-field expressions or descriptors (@hourly,
@every 30s), evaluated in UTC unless prefixed CRON_TZ=<zone>. A window
fires if the leader evaluates it within a minute of its due time; older
windows are missed, except that a trigger with catchUp fires the latest
missed window once (redelivered), unless it is older than catchUpWindow.
jitter spreads firing within a duration after the window is due
(deterministic per window, so re-evaluating it is idempotent). overlap
(skip, buffer_one, default allow) and minStartSpacing refuse to fire
a window while an earlier firing’s task is unfinished, or too soon after the
previous firing; such a window is recorded skipped, not missed. Firing is exactly-once: the task’s
idempotency key is cron:<trigger>:<due> and events are unique per trigger
and due time.
Random triggers (2.6): count times a day, planned within a
window (“HH:MM-HH:MM”, in zone, default UTC), at least minGap
apart, on days (default every day) — plannedRandomTimes is
deterministic, seeded by namespace/deployment/trigger/date the same way
jitterOffset seeds cron’s own jitter, so showing a trigger’s planned
times (next_due_at, via stampRandomTimes) and actually firing them
later always agree, with nothing new persisted. FireRandomTriggers
checks yesterday’s and today’s planned times each tick (only yesterday’s
can still be due and unfired at the start of a day); a time before the
trigger’s own active_since never fires, same no-back-fill rule cron’s
own ActiveSinceMs already enforces. Firing reuses the exact same fire
helper cron does (now parameterized by kind, "cron" or "random",
since the idempotency-key/source prefix must keep the two kinds from
colliding): the task’s idempotency key is random:<trigger>:<due>,
exactly-once by the same construction.
A webhook caller presents the trigger’s secret
(CreateWebhookSecret, agen webhook-secret; only a hash is stored) as a
bearer token, or, with auth.type: hmac, signs the raw body with a platform
secret the deployment is granted (header, prefix, sha256/sha1, hex/base64
configurable). The body (1 MiB max) follows the trigger’s input as the task
input; the idempotency key (the Idempotency-Key header, or the header or
JSON body field the trigger names) makes redeliveries return the same task;
an optional conversation key and the trigger’s labels go on the task.
Rejected calls are recorded as rejected events; configuration errors are
logged on the deployment, not returned to the caller. Event triggers read the
namespace’s event log from a cursor per trigger (trigger_cursors, created
at the newest event); each firing’s idempotency key is event:<trigger>:<seq>,
a trigger skips the events of its own tasks, on.count holds matching seqs
until the count is reached within on.within, and a task records how many
event triggers led to it (event_chain): at 5 the next firing is rejected. A
held provider account keeps the cursor where it is.
Event coalescing (M4, scenario S13): a trigger’s coalesce (a Go
duration, e.g. "30s") merges a matching event that arrives while it
already has a task queued in the target conversation into that task,
instead of firing a separate one — the exact same
Store.MergeIntoQueuedConversationTask mechanism “collect” queue mode
uses, called from fireEventTask before it falls back to a plain
SubmitTask. Unset (the default): every matching event still fires its
own task, same as before this field existed.
Nest failure: heartbeat missing for nest_lost_after → Nest marked lost,
its Assignments are rescheduled, its task leases expire. Singleton sessions
resume from the Store.
Placement (scheduler, every tick on the leader): a deployment’s instances
go to active Nests whose labels match placement.labels. Existing instances
stay where they are; surplus is removed from the most loaded Nest first;
missing instances go to the Nest with the most free capacity (capacity 0 = no
limit). Instances that fit nowhere stay unplaced until capacity appears.
Manager loop: keep the latest assignment set (and keep running it while the
Hub is unreachable); start agen-host serve per missing instance with the
Store URL and instance id in its environment; unpack each Definition once per
digest; drain then stop surplus instances and instances on an old digest;
replace instances that exit or stay unhealthy (crash loops back off); lease
tasks only up to free slots, mark them running, extend leases while they run
(a lost lease cancels the run on the host) and complete them with the lease
id. A task the host could not take is released back to the queue at once
(ReleaseTask, attempt not counted). If a host dies mid-task the task is
released (attempt counted) and re-leased, and its run resumes on another
instance; a task whose lease expires 5 times (Scheduler.MaxTaskAttempts)
fails instead of looping. Instances are tied to the Manager’s lifetime (Job
Object on Windows, parent-death signal on Linux); a host that ignores SIGTERM
is killed after a grace period. Downloaded definitions are checked against
their digest.
Partition: a Manager that cannot reach the Hub for nest_lost_after stops
its singleton instances and does not restart them until the Hub is back,
because the Hub reschedules them elsewhere after the same time. Pools keep
serving from their last assignments.
Delegation lineage of child tasks is derived by the Hub, never taken from
the caller: a child task needs a parent task the calling Nest currently
leases, or, for a run started over A2A (which has no task), an unfinished run
owned by the calling instance on that Nest; its depth is the parent’s depth +
1 (for an A2A run, the number of runs above it) and its root run is the
parent’s.
Delegation stays within the parent’s namespace. On the synchronous A2A path,
the lineage metadata (agen.depth, agen.parent_run_id, agen.root_run_id)
is used only from authenticated agent callers (a Hub-signed call token), and
the host checks it against the Store: the parent run must be a run of the
calling deployment. Anonymous callers, allowed only by a loopback Gateway
(agen up), start a new tree at depth 0.
Nest scope: a Nest token may only lease, complete, release and raise
approvals for deployments assigned to it, fetch definitions assigned to it,
and wake deployments it is eligible to run (placement labels). In its status
reports it may add instance rows only for deployments it is eligible to run;
instances it already reported keep reporting while they drain. Approval
requested_by is set by the Hub.
6. Addressing and A2A
- An agent’s address is its deployment:
<namespace>/<deployment>. Hub.Resolve(ns/deployment)returns the Gateway URLs of Nests assigned to it.- Gateway routes:
/a2a/<ns>/<deployment>(JSON-RPC:message/send,message/stream,tasks/get) and/a2a/<ns>/<deployment>/.well-known/agent-card.json. The agent card advertises A2A 1.0 (gateway.A2AVersion); the JSON-RPC methods and the Task/Message/Part shapes this Gateway serves are unchanged from 0.3, so an older client keeps working without a separate wire path. - A2A request metadata carries
traceparent,agen.depth,agen.root_run_id,agen.parent_run_id, optionalagen.labelsandagen.model_tier, and the request carries the caller’s call token (§10). Tasks carry the same lineage fields. - A message’s
contextId(generated when absent) is its conversation: messages of one context from one caller continue one conversation, and a Gateway runs them one at a time. Two Gateways may still race; the later message then fails with “conversation already has an unfinished run”.
Delegation (sub-agents): an agent delegates only to another Deployment,
either unified through a child Task that call_agent itself waits on
(3.2b, the default for a platform delegate) or synchronously over A2A
(message/send/message/stream, call_agent’s own older path, kept for
embedded/standalone agents, a delegate with a fixed url, or one with
via: "a2a"), or asynchronously as a fire-and-forget child Task that the
caller never waits on (NestService.SubmitChildTask, lineage derived by
the Hub; the start_agent tool). There are no hidden in-process sub-agents;
skills cover in-agent specialisation. Limits (Limits on the Deployment):
max_delegation_depth is checked by the receiving Gateway/Hub from
agen.depth (A2A) or by the Hub at submission (a child task); max_fan_out
is checked by the calling engine per run; max_total_delegations is an
atomic counter per root_run_id in the Store, checked by the calling engine
before each delegation (once per call, by its <run>.<step>.<call> key, so
a retry, resume or suspend/resume cycle is never counted twice).
A bundle lists the deployments it may call in x-agen/config.json
delegates (name, optional namespace, url, description, via); the
engine exposes them through the call_agent tool (and start_agent,
below).
Unified delegation (call_agent, 3.2b, the default for a platform
delegate without a fixed url and without via: "a2a"): the same
ManagerService.StartTask path as start_agent (below), but with no
reply_conversation_key — the child’s result comes back as this call’s
own result, not as a new message — and call_agent then waits on the
child inline, up to limits.inlineWait (default 20s, ManagerService.WaitTask
proxying the Hub’s NestService.WaitTask, which polls like GetTask,
capped at 60s per call). A child that finishes within the wait answers
normally: its outcome’s status and reason prefix the text ([partial: max_turns] <report>; nothing for a plain done), followed by its
structured data if any — the same formatting the A2A path below has
always used. A child that does not finish within the wait makes the whole
run suspend (3.3) instead of blocking the instance for it. call_agent’s idempotency
key for the child start is call:<run>.<step>.<call>, so re-checking it
on resume (after a suspend, or a retry) always finds the same child rather
than starting another one.
Synchronous A2A (call_agent’s exception path: url set, embedded or
standalone use, or via: "a2a"): without a fixed url the target is
resolved through the Nest Manager’s localhost ManagerService.Resolve
(each instance gets AGEN_MANAGER_URL and its own AGEN_MANAGER_TOKEN), which
asks the Hub (NestService.Resolve) and caches the answer, so delegation
keeps working from the cache while the Hub is down — the reason this path
still exists once 3.2b’s unified path, which needs the Hub throughout, is
the default. Resolve accepts a caller deployment that is assigned to the
asking Nest or still has a live instance there (starting, ready, busy or
draining), so a run on an instance draining after a scale-down or redeploy
can keep delegating. The A2A message id is <run>.<step>.<call> and
Gateways run a repeated message id as the same task, so this path is safe
to repeat on retry or resume too. It waits up to 75 min for an answer (a
Gateway lets a call wait 10 min for an instance and run for 1 h), blocking
the instance for the whole wait — no suspension. The callee’s run continues
the caller’s trace (the traceparent names the caller’s agen.tool span,
which records the target as agen.delegate.target) and records
parent_run_id / root_run_id.
Async delegation (start_agent, managed mode, delegates without a fixed
url): the engine asks its Manager (ManagerService.StartTask), which
submits a child task through NestService.SubmitChildTask (retrying through a
short Hub outage) and returns its id; the tool returns at once and the
caller’s run finishes its turn. The child task carries reply_deployment
(the caller) and reply_conversation_key: the caller’s conversation key, or,
when its session has none, async:<session>, which the engine sets on the
session. When the child ends (succeeded, failed or cancelled), the Hub submits
its result once (idempotency key reply:<child>) as a task of the caller’s
deployment with that key and source reply:<child deployment>, so the caller
continues the same conversation. The reply task keeps the child’s root run
and labels but no parent run or trace context: it starts a trace of its own.
The idempotency key of the start is start:<run>.<step>.<call>, so a retried
or resumed call returns the same task; start_agent counts against
max_fan_out and max_total_delegations like call_agent.
Parent-close policy (3.4, part): Task.on_parent_end (“cancel”, the
default and also what empty means, or “abandon”) decides what happens to a
task’s own unfinished children when it ends (Temporal’s term for the same
idea). taskFinished (the Store hook every task-ending path already runs
through, for events, locks and the parent’s run_inbox) applies it: on
“cancel” it finds the task’s direct joined children (reply_deployment
unset) still in queued, leased, running or waiting, cancels each one
(ending its run the same way CancelTask does), and recurses into
taskFinished for it — so a whole branch unwinds together, regardless of
how deep it delegated. A child with reply_deployment set (every
start_agent child today) is async by design — the whole point is to
outlive the task that started it — so it is never cascaded, on either
policy. “abandon”
skips this and leaves them running with no parent left to report back to
(their own reply_deployment/inbox target, if any, still applies). It is
set on SubmitTask/SubmitChildTask/agen run --on-parent-end; neither
call_agent nor start_agent expose it yet (their children keep the
default).
Tree queries (3.4, part): Task.root_task_id names the tree a task
belongs to — a task with no parent is its own root; SubmitTask defaults
it to the task’s own id when a caller leaves it unset, and linkToCaller
and SubmitChildTask both carry the parent’s own root_task_id down
rather than deriving it fresh, so it stays correct no matter how deep a
chain delegates. Unlike root_run_id (empty until a run attaches to the
task), it is always set, which is what makes GetTaskTree(task_id) able to
resolve a whole tree from any task in it, including one still queued:
it looks up that task, then returns every task sharing its root_task_id
(agen tree <task-id>).
Tree-wide cancel (3.4, part): CancelTask(id, tree=true) cancels every
unfinished task of id’s whole tree, resolved through root_task_id the
same way GetTaskTree does — one flat query, not a parent-by-parent walk.
Unlike the parent-close policy’s automatic cascade (which deliberately
skips async children), this touches every task of the tree, including an
async one: cancelling a whole tree on purpose is a different thing from a
task quietly ending. It still runs through the same taskFinished, so a
task the parent-close policy already cascaded onto while this walk was
running (processed moments earlier in the same transaction) is picked up
and reported too, not double-cancelled. Only hard cancel exists today
(stopped at once, same as the single-task path) — a graceful mode (give
each run a last report turn, the Hub finishing stragglers after a grace
period) is not built yet, nor is tree=true wired into agen cancel’s A2A
counterpart.
Progress and query (3.4, part: 2.33’s query half and 2.34): the
built-in report_progress(step, done?, total?, note?, artifacts?) tool —
registered unconditionally in AgentBuilder::build(), like finish and
save_artifact — calls out to the Manager (ManagerService.ReportProgress
→ NestService.ReportProgress, mirroring WaitTask’s engine-initiated
round trip) when one is configured (AgentBuilder::task_starter; outside
managed mode the tool no-ops with a friendly result instead of erroring).
NestAPI.ReportProgress is deliberately low-stakes: unlike
CompleteTask/SuspendTask there is no lease fencing (the engine has no
lease id to send), just requireNest and a no-op on an already-terminal
task, since the engine has no way to know that before calling. It stores
the latest value on Task.progress and appends a task.progress event,
rate-limited to one per 10-second bucket per task (AppendEventOnce with
a progress:<task>:<bucket> dedupe key) — the event log is the history,
the column only ever holds the latest. QueryTask(id) reads it back
alongside a simplified cut of GetActivity’s own per-run activity
(model/tool/idle/approval, from the task’s open span and pending
approval; not agent for one waiting on a delegate, and none of
GetActivity’s fleet-wide warnings) and the outcome, without GetTask’s
wait_seconds — a lighter read for a caller or a person checking in on
long work.
Signals and queries (3.4, part: 2.33): signal_agent(task, message)
and query_agent(task) are the same idea as start_agent/call_agent
but without going through delegation bookkeeping — a run checking in on,
or sending a message to, a task it (directly or transitively) delegated
to. Both are built-in, registered unconditionally like report_progress,
and both reach the Hub through a descendant-scoped round trip:
ManagerService.{Query,Signal}ChildTask → NestService.{Query,Signal}ChildTask,
each carrying the calling task’s own id (ToolContext::task_id) alongside
the target’s. NestAPI.isDescendant walks the target’s parent_task_id
chain up to the caller (bounded, generous next to any realistic
max_delegation_depth) and refuses PermissionDenied if it never reaches
it — “only your own descendants,” not any task you happen to know the id
of. QueryChildTask is QueryTask’s own logic (factored into
Hub.queryTaskProto, shared by both RPCs) with that check in front.
SignalChildTask writes a run_inbox row (kind: "signal", ref: the
caller’s task id) and emits task.signalled, waking the target if it
happens to be waiting on exactly this signal.
Draining the inbox into a running run (3.4/3.9, part): the gap 3.3
originally left — “turning a run_inbox row back into a tool result or a
message is engine work, not implemented yet” — is now closed for rows
other than "child". Before each model call, Agent::drive_inner calls
AgentBuilder::task_starter’s drain_inbox(task_id) (managed mode only;
a no-op without a starter, or without a task_id — standalone/embedded
runs have neither), which reaches ManagerService.DrainInbox →
NestService.DrainInbox → Store.DrainInbox: claims (sets
consumed_ms, atomically with the read) every unconsumed row of the
task other than "child" — those stay the call_agent resume path’s own
business, never read generically — oldest first. A "signal" row becomes
a new user message, "[Signal from task <ref>] <payload>", folded into
the very next request to the model; any other kind is claimed (so it
does not pile up) but not yet turned into anything, ready for "cancel"
(graceful tree cancel) or "steer" (queue mode) rows once those exist.
Like ReportProgress, NestAPI.DrainInbox has no lease fencing
(requireNest only) — low stakes, since claiming is idempotent and the
worst case is a stale instance’s drain racing a real one for the same
row. A failed drain (the Hub unreachable, say) is logged and the turn
goes on without it — this must never be the reason a run stalls, the
same invariant the direct-Store write path protects for runs/spans.
Join (3.4, part): start_agent without detach: true (the default,
decided by the user 2026-10-23, §9 item 2 of the implementation plan — a
deliberate behaviour change from every earlier version) ties the child to
the caller’s own task instead of replying into the conversation: no
reply_conversation_key is sent, so Task.reply_deployment stays unset,
the same signal the parent-close policy already uses to mean “joined.”
Two checks share one TaskStarter::unfinished_children(task_id) →
ManagerService.UnfinishedChildren → NestService.UnfinishedChildren →
Store.CountUnfinishedChildren round trip (counts only direct, unfinished,
joined children — async ones and grandchildren never count): the built-in
finish tool refuses a done call while it is positive ("N child task(s) you started are still running; wait for them (query_agent) or cancel them, then call finish again", a normal tool error, not a run
failure), and Agent::drive_inner checks it unconditionally at the same
point it drains the inbox — before every model call, not only after
finish is attempted — suspending with wait_for: "children" instead of
asking the model again. Unlike call_agent’s inline-wait suspend, this
one does not wait for the child to be slow at all: a run that starts a
joined child and has nothing else pending suspends immediately. Waking
reuses 3.3’s own run_inbox/taskFinished wake-on-match, extended
(writeInboxRow’s matching SQL) to also satisfy a "children" wait on
any "child" kind row, not only an exact "child:<id>" — so whichever
child finishes first wakes it, the model decides whether to keep working
or try finish again. Neither check ever blocks or suspends on a failed
RPC (the Hub unreachable, say) — only a confirmed positive count does,
the same fail-open posture as every other platform-only check. Found by
the real end-to-end test that was written specifically because this
change was considered the riskiest of the session so far, not caught by
any mock: UnfinishedChildrenResponse.count is an int64, which protojson
renders as a JSON string (to avoid precision loss in JS) — the Rust
client’s first version read it with Value::as_i64(), which returns
None for a JSON string, silently defaulting to 0 and never suspending.
A positive count was never once detected outside of unit tests using a
mock that skipped real JSON entirely. Fixed by accepting either a JSON
number or a numeric string.
Hold (3.4, part): PauseTask(id, tree=false, until?, reason?) /
ResumeTask(id, tree=false) reuse join’s own suspend mechanism rather than
adding a new one. Task gains held, held_until and hold_reason;
LeaseTasks skips a held, still-queued task lazily (its own WHERE
clause, held = 0 OR held_until_ms <= now, needs no active sweep since
the leasing query itself already looks at every queued task each tick).
A leased/running task is not touched at hold time — the engine notices
at its own next turn boundary, the same place it checks
unfinished_children: TaskStarter::is_held(task_id) →
ManagerService.IsHeld → NestService.IsHeld → Store.IsHeld (effective
held: held && (held_until_ms == 0 || held_until_ms > now), so a passed
deadline reads as not-held without anything having to clear the stored
flag first) and, if held, drive_inner returns Outcome::Suspended("resume")
instead of calling the model again. ResumeTask is the only thing that
wakes a task waiting specifically on wait_for: "resume" — unlike join’s
wait, nothing else would, since a hold is not an event anything else
writes to run_inbox for. A waiting task’s own deadline (if any) is
woken by a new scheduler-tick sweep, Store.WakeExpiredHolds, mirroring
ExpireOverdueTasks’s reason for existing: a waiting task holds no lease,
so nothing else would notice its deadline passed. PauseTree/ResumeTree
(like CancelTree) touch every task of the whole tree by root_task_id,
including async ones — an explicit hold is not the automatic
on_parent_end cascade. is_held returns a bare bool rather than the
deadline itself: a deadline is the Hub’s job to act on (lazily or via the
sweep), not the engine’s, and a bool sidesteps the int64/protojson string
pitfall unfinished_children hit entirely.
Deployment-wide hold (2.8): PauseDeployment(ref, paused=true, hold_runs=true, until?, reason?)
also holds every currently unfinished task of the deployment —
Store.HoldDeployment/ResumeDeploymentHold apply the exact same
held/held_until_ms/hold_reason columns PauseTask/PauseTree
already use, just scoped by namespace+deployment instead of a single
task or root_task_id. No engine change was needed: a leased/running
task’s own is_held check (above) already notices the flag at its next
turn boundary regardless of who set it. A resume (paused: false)
always clears any hold this put in place, whether or not that call also
asks for hold_runs. Exposed via agen stop --hold-runs [--at|--in] [--reason]; agen start resumes both the pause and any hold.
Graceful cancel (3.4, part): CancelTask(id, tree=false, mode="hard"|"graceful", grace_period_seconds?).
mode: "graceful" gives every task of the scope (just id, or the whole
tree with tree=true) that is currently leased or running a last
turn before CancelTask/CancelTree’s existing hard stop reaches it:
Hub.gracefulCancel writes each one a "cancel"-kind run_inbox entry
(Store.WriteInbox, the same table/mechanism signal_agent and drain
already use) and then blocks — polling, the same h.Poll-interval
pattern GetTask’s wait_seconds uses — until either all of them reach
a terminal state or grace_period_seconds passes (default 20s, capped at
5 minutes), before falling through to the unchanged hard
CancelTask/CancelTree call, which only has an effect on whatever is
still unfinished. A queued or waiting task has no live turn to give a
last chance to, so it is always stopped at once regardless of mode — only
leased/running tasks get the cancel entry. On the engine side,
drive_inner’s existing inbox drain (built for signal_agent) now also
turns a "cancel"-kind entry into a user message asking the model to
call finish with whatever it has, phrased so the model understands it
will be stopped shortly either way; this needed no new engine mechanism,
only a second case in the same kind match the drain already had.
Exposed via agen cancel --mode graceful --grace <duration> and
CancelTask.mode/.grace_period_seconds (feature tasks.cancel-graceful).
No new Task columns, no new sweep: the grace period is enforced entirely
by CancelTask’s own call blocking, so a caller’s response already
carries the final outcome (SUCCEEDED/FAILED/etc. if the run wrapped up
in time, CANCELLED otherwise) with no further polling needed on the
caller’s side either.
steer/interrupt queue modes (3.9, part): conversations.queueMode’s
remaining two values, alongside followup (default) and collect.
steer delivers a new submission into the conversation’s currently
leased/running task as a "steer"-kind run_inbox entry
(Store.RunningConversationTask finds it; conversation ordering
guarantees at most one) instead of queuing it separately — the
drive_inner’s inbox drain turns it into a user message
("[New input for this conversation] <input>") before the run’s next
model call. This is deliberately shaped like a Temporal Signal rather
than an Update: fire-and-forget, no independent result of its own (the
SubmitTaskResponse is the running task itself, the same way collect
returns the task it merged into rather than a new one); a caller after a
result should query that task, not treat the steer submission as having
one. steer’s own idempotency-key gap stands (a repeated steer delivers
its payload again); collect’s is now closed, see below. interrupt reuses graceful cancel’s own mechanism
(Hub.gracefulCancelReason) on the conversation’s running task — a new
Hub.InterruptGrace field (default defaultGracePeriod, since
SubmitTask has no field of its own for a longer one, unlike
CancelTask’s request-level override) — then queues the new submission
as its own, independent task once the old one ends, one way or the
other. This mirrors Temporal’s own cancellation model: cooperative (the
run decides how to respond, same “cancel” inbox entry/wrap-up-and-finish
path graceful cancel uses) and a run that finishes on its own within the
grace period ends however it finished, not cancelled — only a survivor
past the deadline is hard-stopped. Neither mode needed a new Task column,
proto field, or RPC: both are entirely SubmitTask-side branches on the
existing conversations.queueMode string and Store.WriteInbox/
Hub.gracefulCancel, which already existed for signal_agent and graceful
cancel respectively. With nothing running in the conversation right now
(it’s idle, queued, or suspended waiting on something else), both modes
fall through and queue normally, same as followup.
collect’s idempotency-key gap, closed: a merge now checks a new
task_merge_keys table (namespace, deployment, idempotency_key → task_id)
before merging, in Store.MergeIntoQueuedConversationTask — a submission
whose key was already used, either as some task’s own original
idempotency_key or recorded here by an earlier merge, returns that same
task instead of merging a second time; a successful merge then records
its own key there, if it has one. Event-trigger coalescing (above) shares
this same merge call, and the same fix, for free.
SubmitTask.delivery (3.9, part): overrides conversations.queueMode
for one submission, when the deployment allows a mode that reaches it
(any value, including ""/followup, which always falls through to a
plain queued submission regardless of the deployment’s own policy) —
Hub.SubmitTask picks req.Msg.Delivery over PolicyOf(d).Conversations
when it is set, then runs the exact same collect/steer/interrupt
branches as before. Lets a followup deployment still have one urgent
submission interrupt or steer the running conversation without changing
the deployment’s default for everyone else. agen run --delivery MODE.
The rest of the “Tree” design is otherwise done; 3.9’s queue modes are
now all four built. These eight (parent-close, GetTaskTree, tree-wide
cancel, progress/query, signals/queries, join, hold, graceful cancel) are
3.4’s pieces so far.
7. Engine model
Agent → Session → Conversation → Run → Span.
-
IDs are ULIDs;
gen_ai.conversation.idis used for conversations. -
Everything is persisted through the storage port (SQLite and Postgres adapters). Spans live in the Store, not in local files; JSONL and OTLP are export options.
-
Write path:
agen-hostwrites sessions, runs, spans, effects and logs directly to the Store; nothing goes through the Hub, so agents keep working while the Hub is down. The Manager passes the connection string to each instance as a secret environment variable (local: the SQLite path; distributed: a per-Nest Postgres role limited to run-data tables). The Hub reads the same tables for the fleet view, logs and daily budgets. If the Store is unreachable, new runs fail fast and in-flight runs stop at their next checkpoint write. -
Cassettes and
agen replay(3.6, part): a deployment whosex-agen/config.jsonsetsrecordInteractionshas its real provider wrapped inRecordingProvider(from_harness, which already builds the “fake”/“replay”/“openrouter”/“openai” providers from the same file — this was the only new wiring needed, sinceReplayProvideritself already existed, built for hand-authored test cassettes viaharness.json’s own{"provider": "replay", "cassette": "..."}, just never driven by a production run’s own recording before). Once a run ends,agent.rssaves the wrapped provider’s recorded interactions (full prompts and tool-call payloads — aModelProvider::recorded_cassette()trait method,Nonefor every other provider) straight to the Store, the same direct write path as everything else here; off by default.agen replay <run>(GetCassette+GetDefinition) reassembles the run’s own bundle with itsharness.jsonrewritten to point at the saved cassette, then drives it withagen-host run’s existing standalone path — deterministic, offline, no Hub task. Cassettes age out with the same retention sweep as spans and logs (PruneObservability), not a separate setting. Resuming from a specific turn with a live model from there on (--turn/--model) needs a provider that can switch mid-replay from the cassette to a live call, which does not exist yet. -
agen eval(§6 learning and testing, part):agen eval <bundle-dir> --scenarios DIR --runs Nruns every scenario file N times and reports pass@1/pass^k. A scenario names aninput, optionalmockedTools(tool name → canned response), optionalrules(mustContain/mustNotContainsubstring checks) and an optionalrubric(criteriapluspassThreshold, scored 0–1 by one extra call to the bundle’s own model provider — a judge prompt, not a new provider). Mocking connects to the bundle’s realmcp.jsonservers once, to learn their tool specs, then never calls them again: a mocked tool’s call returns the scenario’s canned value directly, and any tool the scenario does not mock errors instead of reaching a live server, so repeated eval runs stay deterministic and side-effect free. All of this is local:agen-host eval(new subcommand) does the run+score work per scenario file,agen evalloops it the same wayagen replaydrivesagen-hostdirectly, no Hub task or deployment involved.Eval history (2.43, part):
eval_reports(namespace, deployment, definition_digest, the CLI’s own per-scenario JSON report, created_ms) persists every reportagen deploy --require-evalruns, viaRecordEvalReport/ListEvalReports(the same write+list RPC shapeRecordFeedback/ListFeedbackalready use).agen deploy <bundle-dir> --require-eval [--scenarios DIR] [--eval-runs N]: scenario files default tox-agen/evals/inside the bundle (or--scenariosexplicitly); every one must pass^k or the deploy never happens — noCreateDeployment/UpdateDeploymentcall is made at all. Once it deploys, the report is recorded against the now-created/updated deployment; a report already on file for that deployment is compared against the new one first, printing one line per scenario that flipped (regressed: <name> now fails (passed last eval)/fixed: <name> now passes (failed last eval)) — silent for anything unchanged or new, since only a change is worth a look.agen eval history <deployment>reads the recorded reports back, newest first.replay --turn N [--model](replay recorded calls up to a point, then switch to a live model) still needs a new replay-then-live composite provider, a separate, meaningfully-sized piece of engine work — not attempted here. -
Run ownership: every run has an
ownerand anepoch. Every checkpoint (message + progress, one transaction) and the final status write check the epoch;resumeclaims the run and increments it, so a previous owner that is still alive is fenced off at its next write. A conversation has at most one unfinished run; a second concurrent run is refused. -
Tasks are idempotent: a task maps to at most one run (unique
task_id). Running a task again returns its result if finished, or claims and resumes its run if not. This is how a re-leased task continues on a new host after its Nest died. Only the Manager (by re-sending tasks) resumes runs; hosts never resume other instances’ work on their own. -
Effect ledger ownership: ledger writes are epoch-checked like checkpoints, so a fenced-off owner cannot claim or complete an effect. Timeouts, cancellations and lost connections leave the effect
startedand the model is toldeffect_unknown. -
Model context: history from earlier runs in the conversation is trimmed oldest-first to fit
context_tokens(harnesscontextTokens, default 64000, lowered to half the model’s window when OpenRouter reports a smaller one; always starting at a user turn). When the current run’s own messages exceed it, their oldest tool results (longer than 600 characters, except the latest four andload_skillresults) are cut to their first 300 characters plus a note naming the tool and turn, in blocks of eight, until the run fits in about two thirds of the budget; working in blocks makes the request prefix (and so the provider’s prompt cache) change rarely. The Store keeps every message in full. Tool calls without a recorded result (e.g. cancelled) get a synthetic error result so the history stays valid. -
System prompt:
agent.md’s body, then the full instructions of each skill inpreloadSkills, then a list of the other skills (name and description) to load withload_skill. -
Model turn: the configured model, retried on rate limits and server errors, then each of
fallbackModelsin order. An empty answer (no text, no tool calls) is asked again once before moving on, and an answer cut off at the output limit with nothing usable is asked again once with twice the limit; if every model gives none, the run fails withno_answer. Account errors (HTTP 401/402/403) end the turn at once (§8), unlessharness.fallbackProvidersnames another provider (2.30): each one is a full, separate account (its own provider, model, fallbackModels, baseUrl, apiKeySecret), tried in order only on an account error — never a transient one, and never just because a model on the current provider kept failing (that’s whatfallbackModelsis for). Built the exact same way the primary is (from_harnessagainst a bundle clone with only the harness swapped), so a fallback entry gets script/ cassette path resolution,${SECRET}expansion and the api-key-secret env-var fallback for free, with no second config surface. A model-call span records the served model (gen_ai.response.model), the model sent andagen.fallbackwhen a fallback model served it,gen_ai.provider.nameandagen.fallback_providerwhen a fallback provider served it instead, and for routing models the tier (agen.router.tier) and the router’s reported decision (agen.router.*). -
Budgets: token budgets always apply; USD budgets only when the provider reports cost (OpenRouter does, direct OpenAI does not).
-
Guards: tools a static
denyrule matches are left out of the model’s tool list; the same call failing with the same error 3 times in a row is refused;limits.stuckRepeatsidentical turns (calls and results) get one nudge, then the report call with reasonstuck. Direct OpenAI requests sendmax_completion_tokensinstead ofmax_tokens. -
Outcome: every finished run stores an outcome (
runs.outcome, JSON: status, reason, summary, data, artifacts, inputFrom) and the Manager copies it to the task (CompleteTask.outcome,tasks.outcome). The engine’s built-infinishtool sets it (checked against harnessresult.schema; it bypasses permissions); its accepted call is recognised from the stored turn, so a resumed run ends the same way. A plain answer isdone. AtmaxTurns,maxRunDurationor a run budget the engine adds a user note and makes one call withtool_choice: noneandlimits.reportTokensoutput, and the run endspartialwith that report (reportTokens: 0: it fails as before). Work ended by the Hub (cancel, lease expiry) or by older hosts gets the outcome its state implies in API responses. The Gateway mapsneeds_input/rejectedto A2Ainput-required/rejectedand addsagen.outcomemetadata and a data part. -
MCP server isolation: stdio servers get a cleared environment (a small allowlist such as PATH/HOME/TEMP) plus only their declared
env, never the host’s credentials. They run in a Job Object (Windows) or process group with parent-death signal (Linux) so they die with the host. A dead server makes the host reportready: falsein Health so the Manager replaces it. Reconnect in place (2.13): independently of that whole-host replacement, a single tool call whose own server died retries once, against a freshly reconnected server, for a call safe to repeat (idempotent, or read-only in the first place) —ServerConnholds the one live connection per server, sharedWeak(so it never keeps the server alive on its own, the same property aPeerclone always had) across every tool of that server, so a reconnect replaces what all of them see, not just the one that noticed first. -
Host callbacks: a running agent reaches the platform only through its Manager’s localhost
ManagerService(approvals, platform secrets, resolve with call tokens). Hosts never call the Hub. -
Platform secrets are bundle secrets with
source: platform:- They are stored per namespace in the Store (
agen secret set NAME, value on stdin), sealed withAGEN_HUB_KEKwhen set. The API never returns values, and setting them is an admin action. - The Hub gives a value to a Nest only for a deployment assigned to it
whose current definition declares that lookup key with source
platform. - The Manager keeps values in memory only, so an instance restarted while the Hub is down still starts. The engine registers them with the Redactor like every secret.
- They are stored per namespace in the Store (
-
Checkpoint after every step. A tool marked
side_effect: true(the default for MCP tools unless the server marks them read-only) is written to the effect ledger before it runs and completed after. On resume, a started but unconfirmed effect is not re-run; the run fails the step witheffect_unknownunless the tool declares an idempotency key. MCP tools are idempotent (keyed by their arguments) when the server marks themidempotentHintorconfig.jsontools.<server>.<tool>.idempotentsays so. -
The tool calls of one model turn run concurrently (
harness.parallelToolCallsturns it off), unless one of them needs an approval; results are recorded in call order. -
Every MCP call carries
_metakeysagen/namespace,agen/deployment,agen/taskId,agen/runId,agen/rootRunId,agen/conversationId,agen/conversationKey,agen/labels,agen/trust(2.12, part — the calling run’s current trust, already lowered by any taint, so a tool server can decide for itself whether a sensitive action fits the caller),agen/workspace(2.45, part — the run’s workspace directory, empty when the bundle sets noworkspace),agen/locks(1.5, part — every lock the run currently holds, name -> fencing token, omitted when it holds none) and thetraceparent.
A tool server submitting its own task (2.12, part — documented, not
built further): a tool server reached via _meta already has everything
it needs to submit a same-conversation task today, with its own public
API token, no new mechanism at all — pass _meta’s agen/conversationKey
as SubmitTaskRequest.conversation_key (so it lands in the same thread,
sharing memory and history) and agen/trust as SubmitTaskRequest.trust
(narrowing never raising it, same as any other caller), copying
agen/labels through if it wants them carried along. This is deliberately
not a true tree-linked child task (no shared root_task_id, no tree
budget narrowing, no automatic cascade-cancel when the calling run ends):
SubmitChildTask, the RPC that gives those, is NestService-only,
authenticated by a Nest’s own mTLS credential — a tool server has none,
and the public SubmitTaskRequest has no parent_task_id field at all.
Giving a tool server real tree-linkage would need a new, security-
sensitive mechanism first — some signed capability token proving a given
call genuinely acts on behalf of a specific task before the Hub accepts
a claimed linkage on the public API, comparable in sensitivity to the
A2A call-token trust boundary (§3.7) — not a quick SDK addition, and not
attempted here.
Task workspaces (2.45): config.json workspace: {mode, retain}.
mode is per_task (its own directory, removed when the run ends unless
retain), per_conversation (shared by every task of one conversation,
keyed by conversation_key, never auto-removed) or shared (one
directory for the whole deployment, never auto-removed). The host
creates it (AGEN_WORKSPACE_ROOT, default the OS temp directory’s own
agen-workspaces, namespaced by namespace/deployment) before the run’s
first model call, tells the model about it in the system prompt, and
passes it to every MCP server via _meta agen/workspace — any tool
server that wants to read or write files there reads that key itself;
the host does not pass it any other way. A task or run id missing (the
run has neither, e.g. standalone use) falls back to the run id, so
per_task/per_conversation still get a directory of their own rather
than colliding on an empty key. Directory creation and removal are both
best-effort: a failure is logged, never fails the run over something
optional.
- A failed tool call’s span carries
error.message(redacted, at most 1000 characters), so a trace explains the failure without the transcript. - A run’s top-level span is written as
unfinishedwhen the run starts. On resume after a crash it is closed asinterruptedand the resumed run’s span hangs under it, carrying the task attempt. - Each model call’s
max_tokensis the configuredmaxOutputTokenslowered to the remainingmaxTokensPerRun(bundles default to 8192; an SDK agent without a maximum sends one only once fewer than 8192 tokens remain). - In managed mode the Manager passes the definition digest it unpacked
(
AGEN_DEFINITION_DIGEST); hosts do not re-hash the directory, which tool servers may write to. Health reports the loaded tools and each MCP server’s state; the Manager forwards them to the Hub’s instance view. - Permissions: rules evaluated deny → ask → allow, first match wins; default ask.
A rule matches a tool by its registered name (
server.tool) or its wire name (server_tool). “Ask” creates a durable Approval (embedded mode: callback to the host app). Approval arguments are redacted before they are persisted or shown. Withpermissions.notify, the Hub POSTs each new pending approval to that URL, signed with a granted platform secret if one is named. - Trust (3.7): every task has one (
operator>trusted>untrusted), set by its source (an API token’strust, defaulttrusted; a webhook trigger’s, defaultuntrusted; cron, alwaystrusted) and narrowed, never raised, by a child (start_agentsends the caller’s current trust). A tool markeduntrustedOutput(bundle config, or an MCP server’sopenWorldHint) drops the run’s trust tountrustedfor the rest of the run the first time it is called, logged asagen.trust.loweredon the tool span. A permission rule’swhenconditions it on the run’s current trust and more (1.3), evaluated alongside the plain deny → ask → allow match:trust/trustBelow(the engine tracks the run’s trust in memory; it is not persisted, so a resumed run restarts from the task’s original trust),args(every named argument of the specific call must match — a glob, a numericlt/lte/gt/gte, or else an exact value; a call argument the rule names but the call omits never matches, and a rule with anargscondition never narrows the tool list shown to the model, since no specific call exists yet there),labels(every named label of the task must equal this value) andtime(a “HH:MM-HH:MM” UTC time-of-day window, optionally also limited todays; no time zone database dependency).rate, a count in a window, is not yet implemented — it needs its own persistent counter, a bigger piece than the others here. - Feedback: a judgement on how a task went (score 0.0-1.0, labels, a note, who
gave it), recorded by
RecordFeedbackand read back byListFeedback.DecideApprovalrecords one too (by: "approval", score 1.0/0.0), so approvals and explicit ratings land in the same place. - Memory blocks (3.10, part): durable, namespace-scoped named text
(
memory_blocks: namespace, id, label, description, value, limit_chars, a version bumped on every update), attached inx-agen/config.jsonmemory(block,mode:read/append/write) and rendered into the system prompt, read fresh from the Store on every model call — not just once at the start of a run — so two parallel conversations of the same agent already see each other’s writes at their very next turn. With a non-readattachment the engine registers amemory_updatetool (replace/append/str_replace, an optionalexpectedVersioncompare-and-set) so parallel conversations cannot silently overwrite each other;SetMemoryBlock(people, viaagen memory set) always replaces the value outright. The samememorylist also carries live context sources (1.8): aurlentry instead ofblockis fetched with a plain HTTP GET before every model call (maxChars, default 4000;timeoutSeconds, default 5), rendered after the durable blocks as## Live: <url>; any failure (unreachable, non-2xx, timeout) just leaves it out of that turn’s prompt rather than failing the run — it is read-only, somemory_updatenever lists it. Anmcplive source (reading an MCP resource instead of a URL) is not yet implemented — this engine has no MCP resources/read client at all yet, today it only calls tools.
Rollover (1.8, part), x-agen/config.json rollover: {prompt}, off
by default (no key at all: the exact old trimming behavior, nothing
changes). request_messages already trims a conversation’s own history
oldest-first once it no longer fits context_tokens; with rollover on,
the chunk about to be dropped is folded into a running summary — one
extra call to the agent’s own model provider, the same one-off-call
pattern agen eval’s judge uses — instead of just disappearing.
conversations.summary/summarized_through (how many of the
conversation’s own messages, oldest first, are already folded in) persist
it; a message past that point is never resent raw again, but stays in
messages, fully readable by anyone reading the conversation back
directly — rollover only changes what reaches the model, never what the
Store keeps. The summary is prepended to what’s sent as a plain user
note, [Earlier conversation summary], every turn it’s non-empty, not
only the turn a cut just happened on. A failed summarization call logs a
warning and drops the chunk without one, same as before rollover existed,
rather than failing the run over something optional.
- Artifacts (3.8, part): named, content-addressed text (
artifacts: namespace, id =sha256:<hex>of content, name, media_type, size, content, task_id, run_id) an agent saves with the built-insave_artifacttool and reads back withread_artifact(paged byoffset/length), or people read withGetArtifact/ListArtifacts(agen artifacts). Likefinish, both tools are available to every agent and bypass the bundle’s permissions and any task overrides entirely (storage mechanics, not an action on the outside world). Saving the same bytes again in a namespace returns the existing row. Limits: 25 MB per artifact, 500 MB per namespace. Tool-result cap (2.23/2.36, part):harness.maxToolResultChars(default 32000 characters, 0 off) is applied inAgent::invoketo every successful tool call’s result, after redaction — MCP tools,call_agent/start_agent/query_agent(delegate answers reach here the same way any other tool’s result does, no separate mechanism needed) andsave_artifact/read_artifactthemselves alike. An over-cap result is saved as its own artifact (the redacted text, so a secret never reaches durable storage unmasked; identical content dedupes to the artifact already saved) and replaced with a preview plus the artifact id and aread_artifactcall for the rest. Carrying an artifact id over A2A, infinish’s own text, and Hub tools returning compact rows by default are not yet implemented. - Secrets: resolved by name (env or the platform secret store; the OS keychain is reserved). Values are registered with a redactor that scrubs logs, spans, stored messages and tool results before they are persisted or sent to a model.
- Budgets per run (tokens, USD) enforced by the engine; per-day budgets enforced by the Hub from reported usage.
7a. SDK contract
- Host tool calls, approvals and run events are delivered on one dispatcher thread per agent, in order. Callbacks must return quickly (hand real work to another thread); a slow callback delays every run on that agent.
- Calling
run()from inside a callback is rejected withreentrant. close()rejects new runs, cancels running ones and fails pending host requests, so in-flight runs return promptly (statuscancelled).- Every SDK passes the shared conformance suite in
spec/conformance.
8. Providers
The ModelProvider port has adapters: openrouter, openai (direct), fake
(scripted, deterministic) and replay (cassettes recorded from a real provider).
The provider is chosen in harness.json only.
- Request extras:
harness.openrouter(provider,plugins) is merged into every OpenRouter request as given;reasoningEffortbecomesreasoning.effort(OpenRouter) orreasoning_effort(OpenAI). - Routing models (
provider/router.rs):typesafe/jev-router(pluginjev-router, tierslow..high),openrouter/auto(auto-router) andopenrouter/auto-beta(auto-beta-router), bothlow..max. A run’s tier is the task’smodel_tier, elserouter.tier, capped byrouter.maxTierand the router’s top tier; under budget pressure it is at mostrouter.budgetTier(defaultlow). The tier andallowedModels/excludedModelsgo in the router’s plugin entry (cost_tier), and the request asks for OpenRouter metadata so the decision can be recorded. A tier is a band the router picks within, not a price cap. Tiers are checked per router when a bundle is deployed (Go,bundle/harness.go) and loaded (engine). - Error kinds: 429 and 5xx are retryable (with backoff, honouring
Retry-After); 401, 402 and 403 are account errors (provider account error), never retried or failed over, except a 403 for flagged input (moderation); other errors fail over to the next fallback model. - Provider holds (Hub,
hub/providers.go): a provider account is<namespace>/<provider>:<key secret name>[@<baseUrl>](a namespace’s platform secrets are its own). When a task fails with an account error, the Hub records a hold (provider_holds) on that account: its deployments lease no tasks, do not scale up for queued work and are not woken untilretry_at. Then one probe task is leased; a probe that fails with an account error backs off further (5 min, doubling, at most 1 h). Any run of the account’s deployments that succeeds after the hold began (task or A2A) ends it (checked by the scheduler each tick).ResumeProvider(admin) ends it at once. New holds and holds ended by a successful run sendprovider.held/provider.resumedto notification targets that take every event.
9. Bundle format
Agent Plugins 1.0 layout plus an x-agen/ extension directory:
bundle/
plugin.json # {"$schema", "name", ...}
skills/<name>/SKILL.md
mcp.json # {"mcpServers": {...}}
x-agen/
agent.md # frontmatter + system prompt body
harness.json # provider, model, parameters
config.json # permissions, triggers, scale, budget, workspace
secrets.json # secret names + source, never values
Every file has a JSON Schema in spec/bundle/. Unknown fields in standard files
are preserved; unknown fields in x-agen/ files are errors.
Definition digest: files are keyed by /-separated bundle-relative path;
ignored paths (.git/, node_modules/, __pycache__/, .venv/, .DS_Store,
*~, *.swp, *.pyc) are skipped; the rest are hashed in UTF-8 byte order of
path as path || 0x00 || u64le(len) || bytes with SHA-256, written
sha256:<hex>. Rust (agen-engine) and Go (platform/internal/definition)
share a golden test vector.
10. Identity and access
- Distributed:
agen hub serve --tlsruns HTTPS with a CA kept in the Store (shared by Hub replicas).agen join-tokenprints a single-use token and the CA hash;agen nest run --hub https://… --join-token T --ca-hash Hpins that CA, generates its key locally, sends a CSR and receives a Nest certificate. NestService then requires that client certificate (its fingerprint is the Nest’s identity); bearer tokens alone, certificates of other CAs and certificates of Nests that never enrolled are refused. The certificate is signed in the same transaction that uses up the join token, and the Nest checks that the CA it is handed matches the pinned hash. API clients use bearer tokens over TLS (--ca-hashpins the CA; the server certificate must also match the dialed host).agen nests revoke IDrevokes a Nest’s certificate and token, ends its assignment stream and reschedules its instances; a revoked Nest never comes back. - Nest certificates are short-lived (30 days,
agen hub serve --nest-cert-lifetime). The Manager renews its certificate over the current mTLS connection with a fresh key when less than--cert-renew-before(10 days) remains, and saves it. The previous certificate keeps working until the new one is first used (so a lost reply can be retried); revocation ends both. Hub server certificates are re-issued at each start. agen hub serverefuses a non-loopback--listenwithout--tls(AGEN_INSECURE=1overrides it, for a TLS-terminating proxy in front).- Nests and hosts need only the run-data tables.
agen store host-rolecreates a Postgres role limited to them (store.HostTables, plus readingschema_migrations), and that role’s DSN is what Nests get.- It cannot read the Hub’s CA or call-token keys, API or join tokens, tasks or fleet state, and cannot write any of them.
- The engine opens the Store without DDL when the schema is current.
- A migration that adds a table hosts use must also extend
HostTables, and the command must be re-run. - With
--namespace(repeatable), row-level security limits the role to those namespaces’ rows, for reads and writes. Child tables follow their session or run. A Nest serving several namespaces maps each to its role withagen nest run --store-for NS=URL. - The Hub, as table owner, is not subject to these policies.
- Row-level security is enabled on the host tables the first time the command runs. A host role made by an older version has no policy then and sees nothing until the command is re-run for it.
- Known limits:
- Hosts write their runs, so fields the Hub reads (
runs.owner, lineage) are only as honest as the hosts of that namespace.requested_byholds the Hub-signed user call token, which a host cannot forge. A host of that namespace can still read it, and reuse it against that one deployment until it expires (10 min), or clear it. Approvals are only as strong as the hosts of their namespace. - Row-level security isolates namespaces only when their hosts are
separated too: separate pods (Kubernetes backend), or separate OS users.
Native hosts run as the Nest’s user and can read its data dir and
environment.
--store-for-onlyrefuses namespaces without a mapping, so none falls back to the default Store URL. - Files holding credentials (
nest.json,resolved.json) are written 0600. That has no effect on Windows; protect the Nest’s data dir with ACLs there. - The Hub’s keys (
hub_ca,hub_token_key) and platform secrets are sealed with AES-256-GCM underAGEN_HUB_KEKwhen it is set.- Use at least 32 random bytes, the same value on every Hub, never given to Nests.
- Keys stored before a KEK was set are sealed in place, but older copies remain in database backups or WAL: rotate them after enabling it.
- Without a KEK (local
agen up) they stay plaintext, and a TLS Hub warns. agen migratecopies sealed secrets as they are: the target Hubs need the same KEK.agen hub rotate-kekre-seals everything fromAGEN_HUB_KEKtoAGEN_HUB_KEK_NEWin one transaction. Hubs started withAGEN_HUB_KEK_PREVIOUSkeep opening values sealed with the old key during the switch (docs/security.md has the procedure).
agen hub rotate-token-keyadds a call-token key.- Hubs publish it at once and sign with it after a 5-minute grace (Hubs re-read keys every minute, and so do Managers).
- The previous key keeps verifying live tokens until the next rotation, which retires it.
- Retired keys are kept, only to verify stored requester records. A requester record that does not verify refuses the ask (fail closed).
- Rotation is refused while the current key is younger than an agent
token’s lifetime plus twice the grace (
--forceoverrides).
- CA rollover is not automated: a new CA means re-enrolling Nests.
- Platform secrets are granted to named deployments (
agen secret set --for). Declaring a name in a bundle does not grant it. - Local SQLite stores are a single file:
agen uphas no roles.
- Hosts write their runs, so fields the Hub reads (
- Local (
agen up, plain HTTP on localhost): enrolment returns a Nest bearer token (scopenest, bound to that Nest’s id) instead. Either way, a Nest may only act for itself and its assigned deployments; join tokens are single-use; user API tokens can never hold thenestscope. - A2A callers present a call token: Hub-signed (Ed25519, key in the
Store), naming the caller (
user:<token id>oragent:<ns>/<deployment>) and the one deployment it may call, short-lived.Resolvereturns one:- to operators, via HubService (10 min);
- to a Manager resolving for one of its instances, via NestService (1 h). The Hub checks that the caller deployment runs on that Nest (assigned there, or with a live instance there, such as one draining), and the Manager caches the token so delegation survives a Hub outage until it expires. Because live instance rows vouch for a caller, a Nest may only add instance rows for deployments it is eligible to run (§5).
- Gateways verify tokens offline with the Hub’s public keys. Nests fetch them
with
GetTokenKeysand keep them on disk, so a restart during a Hub outage still works. - Nest Gateways (
agen nest run) refuse calls without a valid token (--gateway-auth required, the default). Only a loopback Gateway (agen up) may accept anonymous callers. - Lineage metadata (parent/root run, depth) is accepted only with agent
tokens.
- The callee’s host checks it against the Store: the parent run must be a run of the calling deployment, and root and depth come from the parent chain, not from the claim. This works while the Hub is down.
- So neither an outside caller nor another agent can attach a run to someone else’s trace or reset its depth.
- A2A tasks belong to the caller that created them:
tasks/get,tasks/canceland a repeated message id from another caller are refused. Waking a sleeping deployment through its agent card also needs a token. - Managers refresh cached call tokens while the Hub is up and keep them on disk (0600). During an outage a token is used until it expires (agents: up to 1 h after the last refresh), then calls fail with a clear error.
- A run started over A2A with a user token records that user as its
requester (
runs.requested_by). Its approvals then name that user, who cannot approve them. Delegated runs inherit their root run’s requester. - An ask must come from the instance that owns the run (its Manager names
the instance; the Hub checks
runs.owner). There is at most one pending approval per run, tool and arguments, enforced by a unique index, so concurrent asks share it. - A Gateway gives an instance at most
RunTimeout(1 h) to answer an A2A call; after that the call fails and the instance is told to cancel. - Clients (CLI, UI, MCP) use API tokens with scopes
viewer,operator,approver,admin, optionally limited to namespaces. An approval can’t be granted by the principal that caused it. - Local mode: a token is generated on first
agen upand everything listens on localhost only (plain HTTP).agen upkeeps its admin token and Hub URL inAGEN_HOME/local.json(0600; the user profile ACL on Windows) and its Nest credential inAGEN_HOME/nest/nest.json;agen nest rundefaults toAGEN_HOME/nests/<name>. The CLI only falls back to the local token for the local Hub URL. A saved Nest credential the Hub rejects is replaced by a fresh enrolment when a join token is at hand.agen downstops a runningagen upthrough an admin-only local endpoint. - On-behalf tokens (
CreateApiTokenwithon_behalf) are for agents that work for people, like the console assistant. Each call must name a running task (X-Agen-Task, which the MCP endpoint fills from the call’s_metaagen/taskId); the Hub then acts as that task’s submitter with the intersection of both principals’ scopes (admin never passes) and namespaces, and refusesDecideApproval. A task started by a trigger has no person to act for and is refused.
11. API surface
One source: protobuf services in spec/proto/agen/v1/, served with Connect by the
Hub, so the same handlers answer gRPC, Connect and plain JSON over HTTP
(POST /agen.v1.HubService/<Method>). The MCP server at /mcp exposes the same
RPCs as tools, generated from the proto descriptors, so REST, MCP and CLI
cannot drift. The CLI and UI use the same API. initialize negotiates the
MCP protocol revision: it echoes the client’s protocolVersion back when
this server still implements it (its initialize/tools/list/tools/call
shape has not changed), else answers with its own latest
(mcpserver.ProtocolVersion, 2025-11-25).
MCP transport is stateless streamable HTTP: JSON-RPC over POST /mcp
(initialize, ping, tools/list, tools/call), answered with
application/json. A tool call is executed by the same Connect handler as
REST with the caller’s Authorization: Bearer token, so scopes, namespaces
and results are identical; API errors come back as tool results with
isError and the API’s error body. Tool names are the snake_case RPC names;
arguments and results use protojson (bytes as base64; bundles may also be sent
as text in bundleText). The server’s instructions carry a primer on
bundles, tools, permissions and work; GetBundleGuide returns the bundle
schemas and an example; WhoAmI returns the caller’s scopes;
CreateDeployment/UpdateDeployment with validateOnly check a bundle and
return warnings without deploying. GetTask waits up to 300 s and returns
early when the task starts waiting for an approval.
Event log: the Store’s events table holds one row per notable change
(task queued/started/finished/needs_input, approvals, provider holds, missed
triggers and rejected webhooks, deployment changes through the API), written
in the same transaction as the change. Its seq comes from the single
event_seq row, taken as the transaction’s last write: the row lock keeps seq
order equal to commit order, so a reader following a cursor never skips an
event that commits late. ListEvents reads after a cursor with type, label,
deployment and severity filters and an optional wait; the stream below
carries the same events with id: <seq> and resumes from Last-Event-ID.
Events are kept 7 days (pruned hourly by the leader); agen migrate copies
them and the seq. Events announced once (budget thresholds, activity
warnings, task.stuck) carry a unique dedupe key, so several Hubs or ticks
write them once. The leader records GetActivity warnings every 30 s and
delivers notification targets every tick: per target, in seq order from its
stored cursor, one event at a time; a failure pauses the target (growing to
5 min) with the error stored on it, and an event failing for an hour is
dropped and counted.
GET /api/v1/events streams change notices (server-sent events) for the
caller’s namespaces: tasks, runs, approvals, instances and deployments, and
step events: a model call (gen_ai.chat) or tool call (agen.tool)
starting (state unfinished) or ending (its status), with its run, tool,
the agent a delegation waits for (target) and its start and end times. One
watcher per Hub polls the Store once a second while anyone listens (hosts
write run data straight to the Store, so the Store is where changes appear)
and fans the changes out; clients refetch what they show. The web console
uses it instead of polling each view.
A model-call span records agen.message.seq, the reply’s place in its
conversation, and GetTranscript returns the conversation, so a trace view
can show what each call sent and received.
Live and fleet views:
GetActivity: every unfinished run with what it is doing now (model,tool,agent,approvaloridle, and since when), its turns againstmaxTurns, usage, caller and source; queues (due and scheduled, oldest wait); and warnings with a kind and severity:near_turn_limit,near_run_budget,near_daily_budget(80% of a limit),quiet_run(no step for 5 min),waiting_approval(30 min),queue_waiting(5 min),blocked_by_approval(queued tasks while every run waits for an approval),burst(10 tasks in one conversation in 10 min),model_errors(30% of at least 5 calls in 15 min),provider_held,trigger_missedandwebhook_rejected(in the last day).GetMetricsaddsmodels(calls per served model: failures, fallbacks, p50/p95 latency, usage),entries(runs without a parent run, by source:api,cron,webhook, outsidea2acallers, andreplyresults with the agent they came from), and per call edgeasync_calls,active(in flight) andp95_ms.ListProviderHoldsandResumeProvider(§8); deployments carryprovider_holdwhile held.- Cron triggers in deployment responses carry
next_due_atandlast_due_at, computed as the scheduler fires them.ListTaskswithscheduledlists tasks waiting fornot_before, soonest first.ListTriggerEventswith an empty deployment name lists every deployment’s events (of the namespace, or of every namespace the caller may see).
12. Storage
One schema, two engines (SQLite, Postgres), versioned migrations. Tables:
definitions, deployments, assignments, nests, instances, tasks, triggers,
trigger_log, approvals, tokens, sessions, conversations, runs, spans, effects,
usage, provider_holds. agen migrate --to postgres://… copies a local install.
13. Repository layout
spec/ bundle JSON Schemas, protobuf, generated-code config
engine/ Rust: agen-engine, agen-host, agen-sdk-core, agen-ffi, agen-testkit
sdks/ python/, node/, go/
platform/ Go: cmd/agen, gen/ (generated API code), e2e/ (distributed and
Kubernetes scenarios), internal/{hub,manager,gateway,store,cli,
mcpserver,kube,pki,calltoken,...}
web/ React UI and its Playwright e2e
deploy/ docker (image), compose (cluster), kube (manifests)
scripts/ code generation, release, install
examples/ bundles and sample apps
docs/ this and user docs
site/ the docs website (Astro, from Trestle), built from docs/