Documentation — Changelog
Reference · Runtime

Changelog

All notable changes to agentd — the minimal, MCP-native, reactive agent runtime (developed in the agentd-dev org). The format is loosely Keep a Changelog; versions are the released git tags (vX.Y.Z) and the published image ghcr.io/agentd-dev/agentd:X.Y.Z.

Unreleased

The documentation is rewritten as product documentation, for everyone who meets agentd: evaluators, newcomers, workflow authors, operators, security reviewers and integrators. It has five parts — Product, Get started, Guides, Concepts and Reference — with tutorials, task guides, references and use cases, and every complete configuration sample in it is loaded by the binary in CI. Writing it against the code found configuration that was accepted and did nothing, and a deadline that did not bound the run it named; those are fixed below.

Added.

  • The release binaries and the image carry the mock model endpoint (mock:final, mock:file:<playbook>, cargo feature mock-models), so an agent or a workflow can be tried and tested without a model provider.

Fixed.

  • --deadline bounds the run it names. A one-shot --instruction run did not take it at all, and a model call in flight was bounded only by its own timeout; a step's turn now ends when its run's deadline passes, a call in flight included, and the run exits 124.
  • OTLP traces go to observability.otel.endpoint; only the logs did, and traces read the environment variable alone.
  • A --prompt one-shot exits with its turn's outcome — 4 when the model cannot be reached, 7 when a budget refuses or ends the turn, 124 past the deadline — where it exited 0 with nothing on stdout.
  • A system prompt template's #each copied all the template's data for every item, so a long list took time quadratic in its length; it is linear.
  • A subagent's record ends with its child, however the child ends: on a budget (exhausted_steps, exhausted_tokens, deadline), killed by a signal, or torn down by the supervisor. It stayed running, so a wait on it never answered and it held a breadth slot for ever. A child's deadline also bounds its model call in flight.
  • After a restart, a new subagent is not handed a restored one's handle, which replaced the restored record. A turn's or a subagent's MCP agent/idempotency_key is its process's and its child's own — they repeated across turns and across lives, even with a pinned run id, and a server that dedupes would have answered a new call with an old result. A workflow step's key still holds across its retries.
  • A gate answer that is a whole number stays one (5, not 5.0).
  • A wait's on_timeout target that the timeout never routes to is pruned once the wait has ended, so a step depending on both paths goes on with the live one; the run stalled when the wait succeeded.
  • A human step with a schema outputs the answer in that shape — a person's, or the one agent.approval: accept takes — so a later step reads steps.<gate>.output.<field>; the output was the reply text, and a path into it never resolved.

Security.

  • A trust pin whose author key file cannot be read refuses the instruction. It fell back to unenforceable, so under the default warn an unsigned document loaded with a warning.
  • A document that arrived over the wire — a url:, oci: or mcp: instruction, or a subagent template's url: or oci: source — never uses the compose or identity families, whatever the grant or the signature says: the specification's §7.8 floor. Nor does it reach them through blocks every document may use: a :::!config that writes a2a.peers, or a secret named in a header or a step, which would send the operator's credential wherever the document points it.
  • A subagent sees each MCP server as the agent does: the supervisor sends the child the registry's own list of its tools. A tool the operator excluded or disabled is not reachable from a child, and a child's tools carry the agent's names whatever order its servers come in, so a tool a security.policies rule gates is always routed back to the supervisor.
  • A url: document source is HTTPS (plain http:// to a loopback host only) at every redirect hop, is never downgraded from https://, and each redirect target is held to security.egress.
  • In a config file or a workflow file, webhook verifier secrets (bearer, header.equals, hmac.secret) and a2a.principals[].match.bearer_ref are references, as every other credential there is.

Changed (breaking). These load and did nothing; they are refused at load now: a real model connection with no model named; an A2A principal rule matching a client certificate (san, sub) without a2a.tls.client_ca; a model connection's auth that sends no bearer (a static credential under a header other than Authorization, an x509 SVID); an MCP server with both auth and oauth; and, in a binary built without the feature that reads it, a2a.listen, webhooks.listen and a2a.peers (a2a), observability.metrics_addr (metrics), observability.otel (otel), security.aauth and an MCP server's aauth: true (aauth), security.exec.enabled (exec), a model connection's or an MCP server's auth or oauth (oauth), and lifecycle.watch_config (config-watch). A subagent's tools grant names tools as the agent's catalogue does: with a server's ns prefix, and <server>.<tool> where the name is taken. min_success belongs to parallel, which reads it, and is refused on a race, which accepted it and did nothing with it.

Removed. Keys that were accepted and read by nothing: observability.report_file (--report-file), observability.otel.traces, observability.otel.metrics, intelligence.budget.reserve, a2a.conversation_ttl, store.audit, and scope on a budget block.

v1.18.0 — MCP 2026-07-28, MCP Events, and kinds of intelligence

Reading the substrate an MCP Events bridge would stand on (RFC 0045) found eleven defects in agentd's streams, webhooks and resource subscriptions, and none of them needs Events to matter. Two lost events outright: a stream consumer moved past an event its start then refused to fire, and a stream event a webhook had already acknowledged could be overwritten after a crash. The others went quiet without a word in any log: a trim that skipped a lagging consumer, a resource subscription a restarted server had forgotten, a listen stream that ended, a subscribe that failed once and was never asked again. This is RFC 0045's P0. It adds no Events surface, and every fix has a test that fails on the old code.

agentd also implements the Instruction Specification at revision 1.1, upstream 345f275, with the S7 resolution manifest and S27 end matter. CI pins that revision, and both drift checks run against it. Documents gain author notes, end matter, named and conditional rules, reasons, unless and otherwise, typed parameters, overrides, label styles and !eval. Some delivered bytes of existing version-1 documents change too, and the entries below list each one. agentd-instruction 0.3.0 carries this work; its API is breaking (see Crates), and it was published ahead of this release.

And agentd speaks MCP 2026-07-28 (RFC 0045's P1): every connection starts with server/discover, the revision with no handshake and no session, and a server that predates it is reached through the 2025-11-25 initialize handshake as before. Moving there took more than a dependency bump: rmcp 3.5.0, agentd's own fallback (rmcp's would have cut off slow servers and servers pinned to an older revision), and a 2026-07-28 connection that keeps working the way a handshake connection did — its listens are not cut when quiet, an update lost while one re-opened is recovered by a re-read, tool changes still arrive, and every tool call, a turn worker's included, carries the headers its tool requires. It was checked live against the official TypeScript and Python SDK servers and the instruction.md dev gateway before the switch landed.

Then MCP Events itself (RFC 0045's P2, against SEP-3415 — a draft extension, defined for 2026-07-28 only): a server's event occurrences reach declared streams, polled, and everything downstream is what streams already do. Operators bind event types with fixed arguments; inside an operator's grant, a workflow's wait and the model (the events.* tools) subscribe at runtime. The feature ships in the release binaries and says at load that it tracks a draft.

And intelligence comes in kinds (RFC 0046). A model is a chat, reasoning or decision model, reached on a named provider, and every job the agent does — its turns, thinking, deciding, triage, compaction, the goal check and approvals — runs on the model its role names. A decision model answers typed questions with a confidence (decide, classify, route and judge, preflight, the goal check, guarded approvals); where none is configured, the turn model answers through structured generation, held to the answer's schema by the provider. A reasoning model thinks at an effort; a step may be guarded by a decision; preflight may route each turn to a model. Building the guards found the workflow routing edges broken in six places, each fixed below.

MCP Events (RFC 0045 P2, P3)

Added.

  • mcp.servers[].events — a binding: one event type, fixed arguments, delivery (the acceptable modes in preference order; poll in this release), into (a declared stream and subject), replay, rate. Polled on four threads of their own; each occurrence becomes one stream event under a deterministic id, mcp/<server>/<type>/<eventId>, the server's data under data.payload. The cursor never moves past an occurrence that is not durably appended; a 35-minute dedup set and the id make re-delivery harmless; rate holds whatever the server sends; a truncated answer, a re-arm without replay and a schema changed in place are mcp.events.gap.
  • mcp.servers[].event_grants — an event type runtime subscriptions may be made for, with fixed arguments, max live subscriptions, a lifetime and tools. A wait {on: event, subscribe: {server, event, arguments}} subscribes from now, wakes only on its own subscription's occurrences and lets go when it leaves the wait; with tools: true, the root and workflow steps get events.list, events.subscribe, events.unsubscribe and events.subscriptions. Runtime subscriptions survive a restart; every subscribe and unsubscribe is logged and audited. A wait whose subscription is refused, taken by a grant change or not kept across a restart fails, saying why, instead of waiting for nothing; a reload or restart applies a narrowed grant (fixed, tools, lifetime, max) to what is already live; and the trifecta check reads a subscribing wait's grant stream.
  • Discovery at connect, in either capability spelling (SEP-3415's extension key, or the sketch's top-level events some servers still send), only on 2026-07-28 connections; events/list paged and listed again every 15 minutes, or at once when the server says its list changed — asked for on the standing listen with eventsListChanged, and taken off that stream before rmcp, whose listen would end on it.
  • Every refusal is said once at error level with its reason (mcp.events.refused: revision, no_capability, method_not_found, unknown_event, no_common_mode, invalid_arguments, forbidden, not_supported, list_too_large), and nothing is sent where nothing may be. Anything that may pass backs off with jitter and keeps its cursor.
  • Metrics: agent_mcp_events_total, agent_mcp_events_requests_total, agent_mcp_event_gaps_total, agent_mcp_events_dedup_evicted_total, agent_mcp_event_lag_ms, and the kind="event" series of agent_subscriptions_active; the bindings' counts in the status counters.
  • A binding's or grant's stream carries its server's tags (untrusted when untagged) into the load-time trifecta check. Bindings and grants are operator-only: a served document or a subagent template declaring either is refused.
  • The mcp-events feature, in the release set (and the image, deny.toml's graph and the CI matrix).
  • agentd-mcp: McpClient::call_raw (a request the SDK does not model, with what the revision requires, bounded as a whole) and with_listen_extension / rmcp_transport::ListenExtension (filter members and notifications an extension adds to a connection's listen).
  • The built-in mock serves Events on its 2026-07-28 edge (--events extension|top-level|none), webhook delivery included (--hook-ca).
  • Webhook delivery (P3): delivery: [webhook, …], the server POSTing each occurrence to the webhook listener at <webhooks.url>/hooks/_mcp/<binding>. Every arm is a fresh subscription (unsubscribe, then subscribe from the stored cursor) under a secret minted for it and held in memory only, so what the server abandoned while agentd was away is replayed; the lease is refreshed at 80%. The listener checks the Standard Webhooks signature before anything else and answers the verification challenge only under it; an occurrence is answered 200 once durably appended, 429/503 when it cannot be kept yet — and while such a refusal is outstanding no cursor is persisted, so the server's watermark cannot carry it past an occurrence it gave up; a refusal outstanding for 5 minutes pauses the binding until it can re-arm. A refresh reporting failed deliveries re-arms from the cursor; gap and terminated envelopes are taken; removal and a credential change unsubscribe on the connection the subscription was made on. Load checks: the listener, an https:// webhooks.url, no client-certificate listener, a credential on the server.
  • A wait {on: webhook} survives a restart: its record keeps the callback route and its auth references (never a resolved secret), and boot serves the route again. It could only time out after one before.
  • What a run carries is recorded on the stream events it emits, and the runs that consume them carry it: a stream, batch or correlate start's run, and a wait {on: event}'s. A run only the runtime knows to carry outside input (an A2A peer's request started it) no longer launders it through a stream.
  • Webhook idempotency markers are swept on a store without list too (an MCP or HTTP store that offers none): each period's markers are recorded in a small paged index (pages of at most 4 KiB), written before the marker — an index write that fails does not keep the marker from being written — and the sweep deletes through it, keeping a marker whose delete failed on its page until it goes. They were kept for ever there.
  • An MCP server down at boot joins when it comes up: dialed again off the loop (1 s after the first failure, doubling to 60 s), and the connection adopted — its tools added to the registry beside the others' as last built, its subscribe starts, resource waits and Events bindings restored on it (mcp.joined); nothing else moves. Until now it joined only through a reload that dialed it, or a restart.
  • webhooks.url (restart-only): the URL the webhook listener is reached at from outside, behind a proxy — https:// (loopback http:// aside), never a wildcard host, with an optional path prefix. A wait {on: webhook}'s callback URL is built on it, where it was built on the bound listen.
  • agentd-mcp: RawHandler::body_limit — a per-path body cap, answered 413 on the declared Content-Length, the body never buffered (an over-long body was dropped without an answer).
  • A served instruction follows its updates by MCP Events: agent.instruction.mcp: {server, resource, events: [...]} names event types of the instruction's server (instruction.ref.changed, or {name, arguments}), each polled for on the server that served it, and an occurrence reads the instruction again — a burst of them one read, at most one a second (instruction.event). They land in no stream; a binding the operator declared with the same type and arguments is shared, both kept. Beside the resources/updated subscription and the refresh timer, never instead of them: a server without the type or without Events refuses it in mcp.events.refused, and the instruction is followed the other ways.

MCP 2026-07-28 (RFC 0045 P1)

Changed (breaking).

  • Every MCP connection starts with server/discover. A 2026-07-28 server is reached with it alone; an older one refuses it and is reached through initialize offering 2025-11-25 (or the newest older revision it lists). The refusal decides: a -32020/-32021, a 401, an unreachable server or nothing in common fail the connect; anything else — a -32601 in any frame or status, a 403, a 4xx/5xx, {}, a 202, silence until the connection's timeout — falls back. A fallback refused too names both refusals. A re-dial, and a turn worker's or subagent's dial, start the way the server was reached.
  • Headers the MCP client sets itself are refused at load (MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Param-*, Mcp-Session-Id, Last-Event-ID) on mcp.servers[].headers and an MCP service's headers: a copy went out beside the client's own.
  • agentd-mcp's socket hands back the server's answer: a JSON-RPC 2.0 error in a non-2xx body (401/403 excepted) or in an event-stream frame with no usable id is the request's answer; a session is adopted only from an initialize that succeeded. ListenEvent::Ended carries idle, and ListenEvent gains Unavailable and FlagsRefused.

Added.

  • 2026-07-28-only servers are reachable, and mcp.connect says which revision a connection negotiated (protocol).
  • On a 2026-07-28 connection: a listen stream has its own idle bound (10 minutes, never below the timeout) and an idle cut is said at info; after every re-listen the URIs it holds are read again and one that changed, or that could not be compared, is woken once (state converges); a listen for the server's advertised list_changed notifications is held from connect, so mcp.tools_changed and a warm subagent's tool refresh keep working (mcp.listen.unavailable once when the server will not hold it); the connection lists its tools once at connect, so every call carries its Mcp-Param-* headers. A tools/list answer comes back to the SDK whole — a notification a server streams ahead of it goes to the host's queue directly — so the headers do not depend on timing inside rmcp (unless the server sends a request of its own ahead of the answer).
  • mcp.tool.dropped: a tool rmcp drops for an x-mcp-header annotation it rejects is logged, once per tool per connection.
  • agentd-mcp: FirstRequest and start_with, first_request(), capability(name) (any capability member as the server sent it), drain_dropped_tools(), with_listen_idle, AgentdHttp::with_notifications, wire::DISCOVER_PROTOCOL_VERSION, wire::PROTOCOL_HEADERS/is_protocol_header, and the server/discover/subscriptions/listen method names.

Fixed.

  • A server's own refusal reaches the operator — "Unsupported protocol version (-32022)", not "HTTP 400" — and a refused initialize names it.
  • A response stream the SDK drops hangs up its HTTP connection, which at 2026-07-28 is the request's cancellation; a replaced listen no longer holds its connection and the server's subscription until the socket times out.
  • agentd login's RFC 9728 probe finds the challenge whichever check a server makes first: a bare initialize, then, only after an answer without a challenge, a server/discover with the standard headers.

Changed with rmcp 3.5. Up to 16 POSTs in flight per connection (3.1 sent one at a time); agentd's answers to a server's requests are bounded at 5s; an elicitation's $schema and property order are kept.

Workflow routing edges

A routing edge — a switch case, on_error: goto, a wait's on_timeout — runs its target past its dependencies. Building decision guards on routed steps found the edges broken in six places; each fix has a test that fails on the old code.

Fixed.

  • A routed step ran whatever its CEL when said: the edge marked it ready before the guard was looked at, so a guarded case, recovery step or timeout branch was never guarded. The edge now skips only the dependency wait; a recovery step whose guard declines leaves the failure standing, and the run fails with it. A decision guard on a routed step is asked first.
  • Inside a body, a wait's on_timeout branch ran at the body's entry — before any timeout — and the timeout then failed the body anyway. It is parked until the timeout routes to it, pruned when the wait is answered, and the body completes on it.
  • Inside a body, on_error: goto never recovered anything: the body failed on the failure before its target could run. A failure an edge took fails the body only when its target declined or failed; a target routed on in turn answers for itself, so a chain of edges runs to its end.
  • Inside a body, a step depending on a switch case that was not chosen stayed pending, and the body never finished. A body prunes the tail behind an untaken case, as the top level does, and prunes a step that depends on a failure an edge took — a step the top level never runs either.
  • Inside a body, a step could run twice: a step that finished inline drove the body again, and the outer pass then ran the rest of its ready list a second time. Both schedulers now dispatch only a step still pending.
  • A routed step caught running by a crash lost its routing on restore, and waited on the dependency the edge had routed around — for a recovery step, the very step that failed — so the run stalled.

Kinds of intelligence (RFC 0046 P1–P5)

Added (P5 — structured generation, decision guards, routing).

  • Structured generation: every call whose answer has a JSON Schema asks the provider to hold the model to it — think and agent nodes with an output_schema, the think tool's, classify/route/judge, an emulated decision (a decide node, decide call or deciding role with no decision model bound, answered by the turn model), preflight, compaction, the goal check, and the approval: auto judge on a closed ask. OpenAI is sent response_format (strict when every object requires all its properties, else the schema as guidance), Anthropic output_config.format beside a reasoning model's effort, a Claude model on Bedrock the same in additionalModelRequestFields. The wire copy closes each object and leaves out the constraints providers refuse; a schema no provider constrains (a free-form object, oneOf, not, a conditional, recursion) is asked in the prompt on Anthropic. The answer is still validated against the full schema and re-asked on a miss.
  • intelligence.providers.<n>.structured_output: each provider says how it is asked, as the top-level connection does; each link of a fallback chain uses its own connection's.
  • Decision guards: a step's when: may be a decision, {noul: "<statement>", state?, confidence?, model?}, instead of CEL. It is asked as a decide step of its own, <id>.when — in the run, budgeted and durable like any step — on its model or roles.decide: a decision model natively, else the turn model through structured generation. The guarded step depends on it alone and runs only when the statement holds (noul ≥ 0.5) and the answer is sure; otherwise it is pruned as a false CEL guard prunes it (in a nested body, skipped), with the tail that depends on it alone. Its default state is the step's dependencies' outputs by step id, or the run's inputs (a foreach/batch element in its body). Under a confidence (the guard's, else roles.decide's) an unsure answer is escalated where the model names an escalate model and otherwise prunes the step; doubt never fails the run. A step a switch, on_error: goto or on_timeout routes to may carry one: the edge asks the guard first. A guard on a start node, or on a subgraph with a body step named when, is refused at load, and the guard's model is held to the deciding kinds' checks.
  • Per-turn routing: intelligence.routes maps each model a turn may run on to when to use it (fast: "a quick answer", deep: "careful multi-step work"). Preflight chooses one for each message it looks at — on a decision model as one more choice, the routes' criteria its options; on the turn model through structured generation, a route enum in its schema and the routes listed in its prompt — and the turn that answers that message runs on it instead of roles.turn (turn.routed {ctx, model}). No verdict, a route under the preflight role's confidence (preflight.route_unsure; the rest of the triage stands) or a route a reload removed meanwhile (turn.route_removed) leaves the turn on roles.turn; the next message is routed by its own preflight. Each route is a declared chat or reasoning model with a criterion, at most 32, and routes with agent.preflight: never is refused at load. The transcript is compacted to fit the smallest window among the turn model and the routes. Operator-only (a served document that sets it is refused) and reloadable.

Changed (P5, breaking). structured_output is auto (the default: natively where the dialect and the schema allow) or prompt (never a format on the wire, for an OpenAI-compatible server that refuses response_format). json_schema and tool are removed and refused at load; prompt on a dialect: decisions provider, which is sent no schema, is refused too.

Fixed (P5). intelligence.structured_output was accepted and read by nothing: every schema-bearing call asked for JSON in the prompt whatever it said. A step's when: written as an object was dropped without a word and the step ran unguarded; an object is now a decision guard, and any other shape that is not a CEL string is refused at load. A conversation's compaction window was fixed when the conversation was created, so a restart or reload that moved the turn model to a smaller window left a restored conversation compacting against the old one; it now follows the live settings at each turn.

Added (P4 — reasoning).

  • intelligence.models.<n>.kind: reasoning — a model that thinks before it writes, usable wherever a chat model is, with defaults reasoning: {effort, budget}; effort: on think and agent nodes and on the think tool overrides the effort for that call (refused where the model does not reason). Each dialect gets its own form: reasoning_effort (OpenAI, and OpenAI open-weight models on Bedrock), adaptive thinking at an effort (Anthropic, and Claude models on Bedrock) or the budget form when the model names a budget — with no temperature, and the output cap raised by the thinking's room, which budget admission counts. A Bedrock reasoning model whose id names no family agentd knows is refused at load.
  • A turn that keeps calling tools sends the model's own message back as it came (Anthropic's signed thinking blocks, Bedrock's reasoningContent, in order) — to that model only: each message that thought records its dialect and model, so a fallback or degrade model gets the text and tool calls alone. Reasoning never enters the transcript and never outlives the turn.

Added (P3 — decisions in the loop).

  • A role is a model's name or {model, confidence}: the threshold a deciding role's answers are acted on with (decide, preflight, goal, approve; refused on turn, think, compaction).
  • roles.preflight on a decision model: the message's intent, whether it needs a plan, its risk and the skills it calls for, as one decision; a plan it calls for, or a question it says must be asked, is the turn's to write and the turn is told. An unsure answer leaves no verdict (the message is a task).
  • roles.goal on a decision model: achieved and stuck as two statements about the status.
  • roles.approve on a decision model: a closed ask (an enum or boolean answer, at the top of its schema or as its one property) is a choice among its answers, acted on only above the role's confidence — which a decision-model approver must set, and which an escalated answer is held to as well; an open question goes to the model's escalate model when that model writes, or stays UNDECIDED. The judges' decisions are logged (intel.decision, with the role).
  • roles.decide's confidence is the threshold of every deciding node and decide call that sets none.

Changed (P3). No judge, on any kind of model, answers an ask on the operator's behalf when the run, subagent or peer conversation asking carries outside input: the ask fails with that reason (human.judge.withheld). The goal check's and the ask judge's tokens are charged to the instance budget and counted in the status tokens. A preflight that says a plan is needed (and wrote none) or the message is ambiguous now tells the turn so, on a chat model too — only when a turn runs. An auto-answer that does not fit the gate's schema fails the ask and answers the asker (it waited until its deadline); a gate whose schema is a bare boolean or number takes the answer as that value.

Added (P2 — decisions).

  • intelligence.models.<n>.kind: chat (the default) or decision. A decision model answers typed questions about a state — choice, noul, score — natively, on a provider with the new dialect: decisions (the endpoint is the decisions resource's full URL; the body is the shape Jev, OpenRouter and Strands Decider share). A chat model answers the same decision by emulation, one structured call, calibrated: false.
  • The decide node: {state, questions, model?, confidence?, on_unsure?} → one entry per question plus model and escalated; on roles.decide when it names no model. classify, route and judge are the same one-question decision on a decision model (judge gains levels:, a score beside its verdict), and the presets they were on a chat model.
  • confidence and on_unsure (escalate | fail | continue) on every deciding node, and intelligence.models.<n>.escalate: an answer under the threshold is asked once more on the escalate model (final), fails the node, or is handed on unsure: true. The default escalates where it can and otherwise fails — a threshold is never quietly ignored.
  • The decide tool for the agent, on roles.decide: an unsure answer is escalated where it can be, else handed back marked.
  • Kinds checked at load: a decision model only where something decides, a chat model never on a decisions provider, a chat model's fallback never a decision model, the top-level connection never dialect: decisions.
  • intel.decision and intel.decision.escalate log lines (answers, never the state). The built-in mock LLM answers decisions (a playbook's decisions rules).

Changed (P2). classify takes classes as a list or a map of class → what it covers; the route and judge presets ask for and return a confidence, and a chat judge with levels: returns a level. On a decision model the three keep their outputs' keys but write no prose: judge has no reasons and its score is the verdict's probability on its 0..10 scale (with levels:, the position on those levels), and classify and route have no reason. A confidence outside 0..1 is no confidence: a native one is an error, a chat model's fails the threshold. A decision's answers are checked whole; a chat model that misses the shape is told why and asked once more, and the tokens of both are counted.

Fixed (P2). The think tool, preflight and compaction — and now the decide tool — spent tokens no budget saw: their workers' admissions were released as granted and nothing charged what they used. It is charged to the instance and to the conversation they ran for.

Changed (breaking).

  • Model choice per job is intelligence.roles: turn, think, decide, preflight, compaction, goal, approve. Each names a model and defaults to turn; turn is intelligence.model said another way, and setting both is refused ("say it once"). Three keys are removed, not aliased — an old key is an unknown key at load: intelligence.default → roles.turn, intelligence.preflight_model → roles.preflight, context.summarize.model → roles.compaction. --model/MODEL are unchanged.
  • A workflow node with no model: runs on its kind's role: think on think, classify/route/judge on decide, agent/extract/ summarize on turn. With no role bound, every one runs on turn, as before.
  • The goal check runs on roles.goal and the approval: auto judge on roles.approve (both the turn's model until bound). roles.approve is operator-only, and resolves in the operator's own section — its models, its fallbacks, its turn model when unbound — so a served document cannot pick the judge of its own gates by declaring a model under its name or binding turn.
  • A turn model the operator names wins over a served document's, in either spelling (intelligence.model, roles.turn); the document's is dropped with a warning rather than refused as said twice.
  • Refusals name a "model" where they said "tier": … is not a declared model.
  • intelligence.timeout is applied: one call's ceiling on the connection (default 120 s, which every call used — the key was accepted and read by nothing).
  • intelligence.token wins over token_file when both are set, as the load warning always said (the file won at the dial).

Added.

  • intelligence.providers.<name> — further connections, with the top-level one's vocabulary and nothing more (endpoints, dialect, token/ token_file, headers, auth, timeout), each with its own client, failover, breakers and credential refresh, checked by the same rules, egress-checked under egress: closed, an inline token in a file refused, reloadable, and operator-only. Every endpoint of a provider dials with its own credential; the AGENTD_INTELLIGENCE_TOKEN* overrides are the top-level connection's alone. A model reaches one by name: intelligence.models.<n>.provider; a model a served document declares cannot name one. A discovered config file that adds a provider is warned about, as one that sets intelligence.endpoints is.
  • agentd --login intelligence:<provider> logs into a provider's oauth2 (or aws SSO) credential, cached under that target.
  • turn.model names the role and provider; turn.model_fallback says when a fallback served a call; intel.swap.sent when a reload handed running subagents their model again.

Fixed.

  • A model's fallback is walked: when a model is unavailable — every endpoint failed or broken open — the call is asked of its fallback, with the fallback's wire model on its own provider, and down the chain. It was validated and never read.
  • budget.degrade.model reaches the worker resolved — a model's wire name, on its provider; a model's NAME was sent to the provider as a model.
  • A reload set the turn model to the literal intelligence.model, ignoring default and the tiers, and never rebuilt the headers or the OAuth bearer. Nothing about a model is copied at startup now: each call resolves against the live settings, and a reload that moves any connection rebuilds every one.
  • A reload reaches running subagents: each is handed its model resolved again and applies it at its next turn boundary under intelligence.swap_policy. The child always honoured the swap frame; nothing sent it, so a warm subagent dialed what it was spawned with for as long as it lived, and swap_policy did nothing.
  • A restored subagent resolves its own model again; it was respawned on the parent's turn model, whatever it had been spawned on.
  • A subagent's stored payload no longer carries its resolved intelligence headers, which may hold credentials; only the model reference is kept.
  • No temperature is sent to a Claude model — on dialect: anthropic, or an anthropic.… model on Bedrock. agentd asked every call at temperature 0, which Claude Opus 4.7 and every Claude model since refuse with a 400: no current Claude model could serve a single call. Claude models that still take a temperature now run at their own default.

Security (breaking)

  • A stream carries the taint of what feeds it, and a workflow that reads outside input into sensitive + egress is refused at load. The root grant fold judged servers and the subagent.run gate judged a child's servers; neither saw text that reached a run through a stream, and workflow agent steps had no trifecta gate at all. So a config whose servers were all tagged passed, while a webhook into: appended a caller's body to a stream whose consumer handed it to an agent holding both other legs. Every stream now gets a tag set from what the configuration declares feeds it: a webhook or A2A into:, a template's mirror_streams, a run that a webhook or A2A start fires and that emits into it, and an emit from a run that read a tainted stream. A run that reads a tainted stream is judged with every server its model-driven steps reach, and so is any run it starts or feeds, through a workflow or message step, a workflow tool, or the tools that hand text on (message.send, workflow.run, workflow.signal, subagent.run, subagent.send). The pattern this refuses is a reader and an actor in one run, or an actor in a run the reader starts; what passes is the reader/actor split docs/security.md describes ("Streams carry the taint of what feeds them"), where no model-driven step that sees the text holds both other legs. It runs at --validate-config (over inline definitions and those in local files and folders), at start and on every reload (over the whole set, url: and uri: definitions included), and on workflow.create/update; a stored definition it refuses is left out of a reload rather than vetoing it. The refusal names the workflow, the stream, what feeds it, and the servers that bring the other two legs. security.allow_trifecta lifts it with every other trifecta gate. It is coarse and static, per stream and per server, not data-flow tracking; docs/security.md lists what it does not follow.
  • A template param that opens an author note or adds end matter refuses the spawn. The spawn guard refused a param value that made machinery, but a value carrying \n<!-- opened a note — and an unclosed note runs to the end of the text and is never refused, so every operator rule after the param vanished from what the child's model read. A value that made a trailing --- block made end matter, the document's record rather than its text. Both are now refused as "introduced directive machinery", judged against the template's own delivered text, so a template that quotes a <!-- or ends in a --- block of its own still spawns. The machinery check is judged the same way: it read the folded text a second time and refused any spawn whose text did not parse again, but delivered text does not always — a verbatim block quoting MUST[y] twice is a duplicate, and an example quoting [[must/x]] dangles once delivery dropped the rule's name — so such a template compiled at boot and could never spawn. A spawn is now refused for machinery, or a refusal, the params added beyond the template's own.
  • A step's servers: is a cap. With security.policies set, a tool a rule might touch was served through the supervisor's own connections and the step's plan still offered every server's tools, so a step held to notes could call mail. A step now sees only the MCP tools of the servers it lists, and a subagent's gated list only those of the servers it was handed.
  • A workflow tool's grant is enforced. It was not, for root, workflow or subagent callers. A tool declared workflows: false or root: false is no longer offered to that caller.
  • A served document opens a webhook route only under the interface grant. RFC 0042 lets a document write workflows, and a webhook start or a wait {on: webhook} inside one opens a route on the operator's listener. :::!endpoint needed the interface grant to do that; the same route written as a :::!workflow block or a :::!config entry needed nothing. Every spelling needs it now, including what a document references (file:, url:, uri:, dir:), which is judged once it resolves, at startup and on every reload. A subagent template opens no route through either door, since a child has no listener; before, a route in its :::!config fragment was accepted at boot and refused by the child at every spawn. A wait {on: webhook} nested in a body now also needs a listener and, on a non-loopback bind, auth, like one at the top level.
  • A definition the agent stores opens a webhook route only under the same grant. workflow.create and workflow.update stored a webhook start or a wait {on: webhook} (nested ones included) whatever the operator had granted, so a route a document could not open, the agent could. They refuse one now unless agent.document_capabilities holds interface, naming the step and the grant, for every caller the tool is granted to. The grant is restart-only, so a reload cannot drop it; a start without it leaves a stored route out (workflow.stored.invalid) and keeps it in the store until the grant is back.
  • A tainted result read back by a context holding sensitive and egress is withheld. The taint check followed outside text forward into the runs it reached and not back out of them: a workflow, or the root conversation, could read a tainted run's output and act on it holding both other legs. Every way a run's or a child's result is read back — a sync workflow tool's reply, workflow.run with wait, workflow.wait (tool or step), wait {on: run}, workflow.status, status, a workflow step that is not detached, a join, subagent.status, subagent.await, subagent.list, wait {on: subagent}, a plan.update binding's note, the notes a finished run or a child's result leave in the root transcript, and an emit step's note: — now answers a context that holds both legs with the run's or child's status and its text (output, error, step outputs, a child's instruction) replaced by a marker naming why, and logs readback.withheld. Who holds both legs is worked out from the same reach the load-time check judges, counting everything a context can hand the text on to: the root grant with every run it can start and every child those can spawn; a run's model-driven steps and everything it hands its text on to; a child's template. What a run or a child carries is recorded on its own durable record as text reaches it — its definition's taint when it starts and again when it ends, an A2A peer's request, signal or message, and whatever the run, child or peer that started, signalled, spawned or steered it carried — and a reader holding a leg less records what it was handed whole. A result is judged by that record, so deleting or replacing the definition, evicting the spawner's run, or a restart leaves it withheld. A result is withheld only for text the reader was not handed already, so a route's own run reading back its child gets it whole. A context that holds a leg less is handed the text whole and carries it on: its own result is withheld in turn, and what it starts or feeds is judged. No read-back is refused at load. The read-back contracts carry the run taint in their tags, as a sync workflow tool does, so a policy matching untrusted_input sees it.
  • Implicit notes are opt-in. agent.on_workflow_finished defaults to ignore (it was note), and subagent_result is no longer in the default agent.wake_on; a warm child's per-turn note now reaches the root only under subagent_result, where it was written whatever the wake policy said. Each carried a run's or a child's text into the root transcript unasked. A root that relies on either note sets it.
  • A wait's on cannot be templated. It was rendered at dispatch, while every load-time judgement of what a wait opens or reads — the route list and its interface grant, the taint check — read it as written: a stored wait {on: "{{inputs.mode}}"} passed both and opened a webhook route, with the auth the agent chose, once a run said webhook. A templated on is refused at load and by workflow.create/update.
  • Webhook idempotency markers are agentd's own. They lived under wh_idem/, which memory.* did not reserve, so a model steered by an injected payload could forge a marker (a real delivery then answered 200 duplicate) or erase one (a replay fired twice). They live under _wh_idem/ now, which memory.* refuses. Nothing reads the old keys.
  • The parent refuses an _instance.emit into a stream the child's template does not mirror, so the producers the taint check judges are the ones the runtime allows.
  • A workflow tool's tags follow the workflow set. They were derived once, when the registry was built, so a reload that changed only the workflows kept the tags the previous set derived, and a definition written at runtime never moved them. They are re-derived on every workflow change: a reload that re-reads the workflows, workflow.create/update, and a retirement. A retired definition whose runs are still live counts until the last of them lands, so its runs' taint stays on the contracts that can still read their results back — across a restart too.
  • A pinned registry read is bound to the pinned document. The trust pin is chosen by the URI's document id, but the signed doc claim was never compared with it: under a URI pinned for one document, a registry could serve another the same publisher signed, and it verified, with a reader and without. A read whose signature names another doc is now refused (attestation).
  • A signed dir: folder is capped by what its authors attested. Each file was verified, but what it attested was then dropped, so the operator's grant reached the folder whole. The folder is now capped by its narrowest file. Pins whose keys are all registry JWKS uris were also passed over silently for a folder; agent.instruction.unenforceable now decides that case for a folder as it does for one document, so refuse refuses it.

Fixed

  • A think or decide request is answered once. The answer left the request pending, so the think child's exit answered it again — with "think worker exited without a result" — and an agent that read both replies could act on the error though the answer had arrived (seen in CI as the decide tool failing on one feature row). Answering now ends the wait; a context.compact answered on the spot is no longer left pending either.
  • A child's frames are no longer lost when a signal interrupts their read. The supervisor's handlers are installed without SA_RESTART, so a SIGCHLD — any child exiting — could interrupt the thread reading another child's frames; the reader took the interrupted read for the end of the stream and stopped, and every later frame of that child was lost: a turn's tool request went unanswered until the turn's deadline. Seen in CI as a turn that asked plan.get the moment a subagent exited. An interrupted read is retried now, there and in exec's output capture (which truncated).
  • A subagent template's gate channels are announced in its child. A template whose human steps were addressed to a ::!human with a channel loaded, but its child announced nothing, since the prose the child reads no longer declares the ::!humans (the parent's boot warned). The parent now composes them into the child's new agent.gate_channels — operator-only, reloadable; an operator may write it for any configured workflow, and a document's own declaration stands where both name a step.
  • A subagent template's variants are judged by the model its child runs. A template with model: gpt-5.1 under a Claude agent had its :::when{agent="gpt"} blocks dropped and its {agent="claude"} ones kept: every template was folded with the agent's own model. Now its own model: decides (a tier is read as the provider's model), else subagents.defaults.model, else the agent's — and an instance-tier child runs that model: it ignored subagents.defaults.model, and the parent's intelligence.default tier overrode the template's own model:.
  • The TUI and web UI keep saying a session expired. When a session expired while a listing was still out (a slow agent, a slow runner), the listing's late answer set the connection back to polling, and the UI showed a live observation that had ended. Once a terminal failure ends an observation, nothing else reports the connection's state.
  • A resource subscription no owner wants any more is let go of. The set a connection held only grew: a wait that resolved or timed out, a run that ended, an instruction that moved left its URI subscribed for the life of the connection, and the server went on notifying for it. Every 5s the loop unsubscribes what no armed subscribe start, suspended wait on: resource or resource instruction wants (mcp.unsubscribed). A workflow a reload retires keeps its start's URI subscribed when a wait or the instruction still wants it — it used to look at other workflows' starts only. A fresh wait's first subscribe is bounded by the management timeout, like every other subscribe from the loop.
  • A config the loader refuses on reload is said in its own words. config.reload.invalid printed a load failure debug-formatted (Usage("agentd: …"), quotes escaped).
  • subagent.status and subagent.await answer a child that finished without an error. Their reply carried error: null, which their own contract types as a string, so a model asking about a child that had finished cleanly was handed a schema error in place of its status. error is now present only when there is one.
  • A stream consumer moves past an event only once it fired. The offset moved first, and the firing could then be shed under pressure, refused by a freshness freeze, or lost when the inbox write failed (its error was discarded). The event was consumed all the same and never offered again; a batch lost the whole batch and a correlate join a completed set. Firing a start now says what became of it, and a consumer moves past an event only when it was accepted. Runs that pressure or a freeze used to drop now fire later. Two refusals would repeat for ever, so they are discarded by name instead of holding the consumer: an inputs mapping that cannot render (start.inputs.invalid), and a start bigger than store.max_value_bytes (start.too_large, new). Their lines name every event they held. A held join stops walking the stream and keeps its refused set in pending, so max_pending still counts it and no event sharing its correlation is lost. A store read error holds a consumer at that seq (stream.read.failed) instead of skipping a whole backlog unfired; a corrupt record is named (stream.event.corrupt) and skipped. A held consumer's refusal line is written once, when the hold starts.
  • An acknowledged stream event is never overwritten. An event was written under a key derived from the stream head, and the head lives in the debounced manifest, so a process killed between the two left an event past the head that the next life overwrote. Event keys are create-only now: an occupied key is kept and the append steps over it (stream.head.recovered). A webhook into: saves the stream head before its 202.
  • A job waits for its last forward: before it exits. An emit … forward: pushes on its own thread, and a job-shaped instance exits the moment its run is terminal, so the push of the run's last emit was killed mid-dial whenever it was slower than the final checkpoint. The idle exit now waits for every push in flight; each is bounded by its own 30s deadline.
  • A trim past a lagging consumer is said. stream.consumer.skipped (stream, workflow, node, from, to, events) is logged once per skip, and agent_stream_lag{stream,consumer}, named in RFC 0035 and never built, is written on every consumer pass (32 named consumers per start kind, the rest in one other series).
  • A webhook is marked seen only once it is kept. The idempotency marker was written before the fire or append, so a delivery refused with 503 was answered 200 duplicate on the sender's retry and lost. It is written once the run is admitted or the event appended, and a route's signal: goes out at the same point, so a retry is processed and wakes a waiter once. A firing the runtime refuses is no longer answered 202: shed, frozen and a failed inbox write answer 503, an inputs mapping that cannot render 422, a start too large for the store 413, and a respond: sync route is answered at once instead of waiting for a run that never starts. Markers answer replays for seven days and are then swept in whole buckets, without reading a live marker; a store without list keeps them. Markers written by an earlier release are not read, so a replay that straddles the upgrade may fire once more.
  • A server that forgets the session is re-dialed and its subscriptions restored. A restarted MCP server forgot every subscription it held, while agentd's transport never said so: each call failed as a transport error until a reload, and a later subscribe returned early as "already covered". A 404 on a request that carried the session is now SessionExpired, the SDK no longer handshakes again behind the runtime's back, and the runtime re-dials (bounded by the management timeout, backing off 1s to 30s while the server refuses). The subscriptions are restored from what wants them: the armed subscribe starts, the suspended resource waits, and the resource instruction, which is also read again. It logs mcp.disconnect and mcp.connect with reason: session_lost.
  • A listen stream that ends is opened again. The subscriptions/listen pump stopped silently. It now logs mcp.listen.ended and listens again with the same filter, backing off 250ms to 30s; a replaced pump is aborted rather than left listening with the old filter, and SDK notifications, which the pump dropped, wake it. A listen's acknowledgment is checked: a URI it leaves out is refused rather than recorded as watched, and a re-listen that comes back narrower is said (mcp.listen.narrowed) and retried. This path serves MCP 2026-07-28 connections (see the P1 section above).
  • resources/subscribe needs the server's capability. It was sent whether or not the server advertised resources.subscribe, though the docs said it never was, so a server that ignored it left a start or a wait waiting on nothing. A subscribe start now logs start.subscribe.unsupported at error level, a wait {on: resource} step fails naming the capability, and a resource instruction logs instruction.subscribe.unsupported once.
  • A failed resource subscribe is asked again. A URI was recorded before the server call, so a failure left it recorded and every retry answered "already covered" without contacting the server. It is recorded once the server accepts it; adding a URI sends only that URI; and a subscribe that fails in a way that may pass (a timeout, a 5xx, a session lost mid-call) is retried from the loop on a 1s to 30s backoff.
  • A restored resource wait is subscribed again at boot. Its record survived a restart; the subscription that wakes it did not, and boot armed only the subscribe starts, so it sat until its timeout. Boot and reload now share one restore. A wait that can never be woken fails its step (wait.resubscribe.fail): its server is gone from the config, or does not offer resources.subscribe. A configured server that is only down at boot leaves its waits parked, said at error level, until a reload connects it.
  • The launcher writes each terminal line in one piece, so a sign-in prompt is never read, or interleaved, without its "[n more waiting]" count.
  • An instruction's SHOULD NOT: reached the model as **SHOULD:**. The keyword maps to the same kind as SHOULD:, and delivery labelled a line by its kind alone, so a prohibition was delivered as a recommendation; :::should{not} lost its flag the same way. Both now deliver **SHOULD NOT:**, from the registry's keyword flags and negated labels.

Changed (breaking)

  • A2A runs on the A2A project's official Rust SDK. The listener is the official server (a2a-server-lf) over agentd's own RequestHandler, the wire is its canonical ProtoJSON (a2a-pb) and the types are a2a-lf, from a2aproject/a2a-rs; the community a2a-rs crate, buffa, buffa-types and the vendored connectrpc are gone, and so is the crates.io build's need for cmake and a C++ compiler. agentd's identity, roles, admission and error fidelity are unchanged in front of it. Where the old library differed from the specification, agentd now follows the specification:
    • a streaming send and SubscribeToTask close at a terminal state only (A2A 1.0 §3.1.2, §3.1.6) — a task waiting at a gate keeps its stream;
    • SubscribeToTask on a task that has ended is -32004 whatever the request carries: Last-Event-ID no longer replays a task's past transitions (a reconnecting client subscribes again and gets the task as it is, or reads an ended one with GetTask). The TUI and web UI let a stream go at a gate themselves and follow the task again when it moves;
    • a pageSize of 0 or less in ListTasks or ListTaskPushNotificationConfigs is "not specified" (the default page), as proto3 and the SDK read it, rather than refused;
    • task and context ids are the SDK's UUIDv7s (time plus 74 random bits);
    • a message that names a task and a different contextId is refused (-32602) by agentd's runtime, as the old library refused it (§3.4.1).
  • A2A streams are agentd's own. Each task's transitions are published into a per-task channel, and every stream opens with the task as it is and carries only the transitions after that snapshot, in the reactor's order. A blocking SendMessage still waits at most 25 seconds for its task to settle.
  • A schedule that could never fire is refused at load. The runtime drops a clock it cannot read, so a schedule with no cron, every or at, an every or at that is not a duration (the docs offered at: "02:00Z", but at is a delay), a cron that does not parse, or a cron on a build without the cron feature validated and then never ran. An every beside cron or at is refused too, because every is read first and the other never was. An at beside a cron is still the first occurrence before cron takes over. As with the removed fields, a run in flight on such a definition is refused at the first restart, and a stored one no longer loads.
  • --validate-config parses workflow definitions in local files and folders. It parsed inline ones only, so a definition in a file:, a dir: or the workflows/ folder beside the config validated whatever it held, and the first real start refused it with exit 2. These are local reads, so they are now parsed, their config.* references resolved, a name they share refused, and the stream-taint check run over them, as for inline ones. A secret only they name is still checked at start, where --prompt-missing can ask for it. url: and uri: definitions are still read at startup, because reading them dials.
  • An instruction document's names are unique across the whole document, and its keyword lines read the 1.1 grammar. A name was judged among top-level blocks only, so a rule or a machinery block named inside a :::when, a section or another container could repeat a name in use, and could not be referenced. Names are now judged document-wide (S26): a name reused inside a nested container refuses (duplicate-identity), and a wiki-link or @kind/name resolves to a nested block. An @ inside an if, because, title, trigger or description attribute, or a skill's when, is text and no longer a reference; anywhere else an unqualified @name still refuses. MUST[name]: names a one-line rule (S12), MUST (if …): gives it a condition (S15), and a BECAUSE: paragraph after a rule is its reason (S14); a rule that already has because= and is followed by one refuses (because-repeated). A skill's trigger is what the catalogue says it is for, with when as its alias. How these lines are delivered is the 1.1 delivery's, below.
  • An instruction document reads author notes, end matter and !eval, and holds attribute values and overrides to the 1.1 registry. A line that begins with <!-- at column 0, outside fenced code and front matter, opens an author note (S9), which ends at the first line holding -->: nothing in it is parsed, so a commented-out :::!workflow no longer loads and a [[kind/name]] in it is not resolved — in a set's rows too, and the 1.1 delivery below strips it. A document may end with end matter (S27): after a blank line, a ---, a YAML mapping and a closing --- as its last lines. It is the document's record: a document with any block no longer delivers it, and end matter there that is not a YAML mapping refuses the document (end-matter-yaml). A document with no block at all is held to the same rule through the delivery gate below. A bare :::eval is prose, as is any machinery name registered after version 1 (S23); :::!eval loads and needs a name; a target=@kind/name, when written, must resolve. It configures and delivers nothing. A value outside an attribute's schema enum or pattern (::param{type=integer}, :::output{schema=reply}) refuses (attribute-value), on a block, on each row of a set (at the set's line, where the reference reports it, and once however many rows share the value), and on a front-matter parameters entry. A :::when or :::unless with no condition refuses (missing-attribute), and so does an overrides that names a guardrail (override-guardrail) or a stronger rule (override-stronger) in the same document. An unknown bare container's body is read raw to its first closing fence, so a fence inside it no longer opens a block; one nested in another block's body now delivers its text, which was dropped (:::note holding an :::aside delivered A. C. and now delivers A. B. C.). An unknown bare leaf (::aside{…}) stays in the text as written, where it was dropped. A blockquote alert is read as x-grammar.alertOpen reads one — > at column 0 and one word, > [!NOTE] — so an indented one, or > [!SHOULD NOT], is a plain quote and delivered as written, where it was read and delivered as a rule. A MUST[x]: in a YAML section's description is that description's text and declares no must/x. Front matter may be empty (--- then ---), and an integer spec: 1 reads as the version "1".
  • An instruction document is delivered block by block, in its own label style. Delivery re-normalised the whole spliced text, keyword by keyword, so it could not tell a rule from a keyword line quoted in an example, and a rule nested in a body was delivered raw or not at all. Every block is now rendered once, from the block, as the reference does (deliver.ts), and a prose body is delivered by the same rules. For a version-1 document that changes the delivered bytes:
    • A rule's name never reaches the model (S12): NEVER[x]: … delivers **NEVER:** …. A condition renders, **MUST (if c):** … (S15), and a reason goes on its own line straight after its rule, **BECAUSE:** …, keeping its line's indentation and list marker and dropping the blank lines before it (S14); a container's because= goes after its body.
    • An alias delivers its canonical label (ALWAYS: as **MUST:**, AVOID: as **SHOULD NOT:**), and :::info is labelled **NOTE:**, not **INFO:**.
    • A list marker is kept as written: * MUST: and 1) MUST: were rewritten to - and 1. .
    • A keyword line that is indented is prose and delivered as written, because the keyword grammar matches at column 0; it was bolded.
    • An example is quoted (S16): **EXAMPLE{ (avoid)}{ — title}:** on its own line, then the body as written, with no keyword bolded and no reference degraded in it. An output is **OUTPUT{ (format)}{ — title}:** on its own line, then its body as prose (S17); its schema is never delivered.
    • A keyword line in an unknown bare block's body, or a bare :::eval's, is no longer bolded: the body is delivered raw, fences removed (S23).
    • A rule inside a body — an output, a context, a kept when, a tool — is rendered as a rule; one inside a :::context was dropped.
    • Author notes are never delivered (S9), in prose or in any body — a glossary's and an inert block's included — and neither is end matter, an included document's included (S27). A verbatim body keeps <!-- as written, and so does fenced code.
    • A ::!human with may says what the role may be asked for: [human role "x" may be asked (may: approve, deny)].
    • A reference inside an inline code span is no longer degraded, and a line of only spaces or tabs is delivered empty.
    • A skill written with a trigger says when to use it (S11): [skill "x" is available — use it when …; reference it as @skill/x], from trigger or a skill's when, trigger winning.
    • The skill catalogue's body is rendered as the skill's prose would be delivered — rules labelled and never named, notes stripped, variants selected, includes inlined — where it carried MUST[name]: raw. It still substitutes no parameter, and it accounts for nothing the delivery does: an include in it spends none of the delivery's 1 MiB, and an override is neither applied nor found in it.
    • delivery: {labels: plain} delivers MUST: x, and delivery: {labels: tags} delivers <must>x</must> (a multi-line body opens the tag on its first line and closes it on its last; an example and an output stand their tags on lines of their own); the default is still bold (S25). An included document is delivered in its own style. A context, a tool, a form and a glossary are the same in every style.
  • An instruction document's variants, parameters and overrides follow revision 1.1. For a version-1 document that changes the delivered bytes:
    • :::unless and :::otherwise select their body (S10), where both were delivered unwrapped. An unless is dropped only when every condition's key is known and matches. An otherwise is kept when no when or unless in the run right before it was kept — only blank lines and author notes may separate them, in the same parent — and one with no run is always kept; attributes on it are ignored.
    • A parameter value that does not fit its declared type (number, boolean, enum, url, duration) is not substituted: the ${name} placeholder stays, and the default does not take its place. A default that does not fit is no value either, example never is, and only a value that fits is a fact a when or unless matches (S18). A front-matter default written as a YAML number or boolean is now used, where it was ignored, and a YAML list of values is listed by a form as one of: a, b.
    • ${x} inside fenced code is no longer substituted (§3.4 rule 4); in an inline code span it still is, as the corpus pins. Whether a line is code is read from the source, so a rule whose body opens with a fence (**MUST:** ```sh) keeps it a fence.
    • A substituted value is capped at 2000 bytes, cut on a character boundary (§3.5 "size-capped"); a long default written many times delivered a copy each time.
    • A param declares wherever it is written, in a when or any other body as at the top level (§5.2); one in a body was ignored and its placeholder delivered raw.
    • An included document is resolved with its own parameters only (§5.2 include rule 1). The includer's parameter values reached it as when facts, so its variants selected on a value its ${} did not substitute, and the includer's substitution ran over its inlined lines, filling placeholders it left with the includer's values and substituting a value it had inserted again. Now an include sees the runtime facts and its own parameters, and its lines arrive substituted and are not touched again.
    • Where a runtime fact and a parameter share a key, a variant selects on the fact and ${key} substitutes the parameter, as before; the reference lets the parameter win both, and which is right is open upstream.
    • overrides="kind/name" suppresses the rule it names, and its reason (S24). The target is looked up in the document, then in what it includes, in include order and depth first; a guardrail, or a rule stronger than its overrider, in an included document is kept. An overrides declared inside a dropped variant does not apply, and a target the document has only inside a dropped variant is looked for in what it includes. The skill catalogue's bodies are rendered without overrides.
    • The includes of one delivery are capped at 1 MiB inlined together; an include past the cap delivers the not-available note.
  • Machinery folds from an instruction document's top level only, and anywhere else is refused. A :::!tools, :::!workflow or any other machinery block nested in a body — a when's, kept or dropped, a context's, a skill's — parsed, was never folded, and the document loaded as if it were not there: :::!tools deny: [exec] inside a :::when denied nothing. It is refused now (nested-machinery), naming the block and the body it is in; a sub-block in its parent (case in a test) is its parent's and is unaffected. An included document folds no configuration, so one carrying machinery is not available and delivers the not-available note, where its acknowledgement told the model of a workflow or a tool policy nothing had loaded. A machinery opener that only delivery reads — inside an alert's quoted lines, which parse reads as text — is delivered as the text it is.
  • An instruction document nests at most 32 blocks deep. A block inside more than 32 others is refused (nesting-depth) and not read: fences of one length nest, so ten thousand :::when lines overflowed the reader's stack and aborted the process. Delivery renders a container's body from what parse read, where it read every body again at every level, so a megabyte nested a thousand deep took seconds; it now costs what the same text costs flat. A chain of bodies only delivery reads again — an alert quoted in an alert, a keyword's text opening with a keyword — is refused past 64.
  • agentd answers when and unless with host, model and agent, and agent is the model family. Revision 1.1 made agent the model family (claude, gpt, …), and agentd supplied agent=agentd. It now supplies host=agentd; model, the model its turns use by default as the provider receives it (intelligence.default, else intelligence.model, a tier resolved to its model); and agent, that model's family when its id names one (claude-*, gpt-* and o1/o3/o4-…, gemini-*, llama*, mistral*, read from the id's last / segment with a Bedrock anthropic. prefix removed). With a recognised model, :::when{agent="agentd"} is now dropped and :::when{agent="claude"} is kept for a Claude model; with an unrecognised one, agent is an unknown key and both are kept (rule 3). The facts come from the operator's intelligence section, read before the document's :::!config merges under it, so a document cannot choose what its own variants are decided by. environment and locale are not supplied. Subagent templates are now folded with the same facts, where they were folded with none, and so are an OCI re-pull and a registry read. A reload that changes only the model reads a registry instruction again for the new facts; it kept the variants chosen for the old model while turns went to the new one, and reported the reload applied.
  • Delivered text is never delivered twice. Delivery is not idempotent: a second pass turns plain labels back into bold, a fence or keyword line quoted in an example into a live rule, and a quoted <!-- into a note that deletes what follows it.
    • A registry read with md.instruction/resolution: resolved is text the registry already delivered for this reader, and is used as served: nothing is extracted from it and nothing folds. Its md.instruction/deliveredDigest must be the digest of the bytes served, or the read is refused and the running instruction kept ("registry delivered text does not match its deliveredDigest"). A raw read, or one with no resolution, is delivered by agentd as before: the registry stamps deliveredDigest on every read, so the digest alone says nothing about who delivered. instruction.loaded for a resource read gains resolution. Under an agent.instruction.trust publisher pin a resolved read verifies through its delivery attestation — its digest is over the delivered bytes, and its manifest's authored digest is the one the author signed — instead of comparing the author's digest with bytes the author never wrote, which refused every pinned resolved read as a digest mismatch. A pinned source with no reader cannot check a delivery attestation, so a resolved read under it is refused by that name: read it raw, or pin reader.
    • An instance-tier child's instruction is delivered once, by the parent. The parent writes the template's delivered prose, params folded in, into the child's config, and marks the child's environment with that prose's digest; the child uses its instruction as written only when it is an instance child and its instruction is exactly that text. The mark is internal: no config path or AGENTD_ alias reads it. The parent's own check of the composed config before the spawn no longer delivers that prose either; it refused templates the child would have used as written. A child respawned after the parent's restart is told the same, from the composed config it reads.
  • A document with no fence is delivered when it has anything delivery touches, so it can now be refused where it used to load raw. agentd ran delivery only on text with a block in it, so a fence-free document of keyword lines, author notes or end matter reached the model as written: MUST[x] (if c): and BECAUSE: unrendered, and the notes and the record that S9 and S27 say are never delivered, delivered. The gate is now anything delivery renders, removes or refuses: a block (a leaf of a registered kind included), a keyword, alert or reason line, an inline [[kind/name]] or [Label](#kind/name) reference, a column-0 author note, front matter at line 1, or end matter. It applies to the agent's instruction at load, an OCI re-pull and a registry read alike (a registry's resolved read and an instance child's parent-delivered instruction are still used as served). Plain prose with none of these is still the model's byte for byte. Going through delivery is going through its refusals: such a document is now refused for spec: "2", front matter that is not valid YAML, a dangling [[kind/name]], a duplicate MUST[x], or malformed end matter.
  • A dir: source drops each file's end matter, and agent.prompt drops end matter from every source. End matter is a document's record, and only the last thing in a document is end matter, so in a combined folder every file's record but the last sat mid-text as prose. Each file's but the last is now split off before the files are joined, without a warning; the last file's ends the combined document and is read there as the same file's is read alone, so a --- block that is body just before it stays body. Malformed end matter in any file refuses the source, naming the file (… dir <dir>: <file>: end matter is not valid YAML: …). This covers the agent's instruction, a subagent template's and agent.prompt's dir:. A pinned folder's per-file signatures are still checked over each file's full bytes. A prompt from any source, text: and a literal included, now loses its end matter too, with a warning naming agent.prompt and the lines dropped, since a task typed at a terminal can end in a --- block meant as text; malformed end matter refuses with agent.prompt: end matter is not valid YAML: …. Nothing else in a prompt is processed: it is the task, not an instruction document.
  • /hooks/_ is agentd's. A wait {on: webhook} callback is armed under /hooks/_cb/, and configured routes match first, so a route there could take a suspended run's callback. A webhook start path or a wait's webhook.path under /hooks/_ is refused when the workflow is parsed, wherever it came from.
  • The §7 delivery manifest is the S7 shape, and a delivery whose embedded manifest is not is refused. The manifest is typed: counter ids (when#1, not a source line), each parameter's declared source, the facts a variant compared, includes by target, limits with both include_depth and include_bytes (UTF-8), and unresolved. Its signed form is RFC 8785 JSON without authored.version. A registry that still embeds the earlier shape (limits: {}, no unresolved) fails delivery verification wherever a trust pin sets reader.

Removed

  • The start fields nothing read: schedule.tz, schedule.jitter, schedule.catch_up, subscribe.coalesce, subscribe.deliver, subscribe.on_no_listener and signal.deliver. They parsed and changed nothing (a tz ran its cron in UTC regardless). A workflow that names one meets the generic unknown-field refusal. That holds for what was stored before the upgrade too: a run in flight on a definition that named one is refused at the first restart, because the definition it is pinned to no longer parses (workflow.pin_missing, then run.refused), and a definition stored with workflow.create/update that names one no longer loads (workflow.stored.invalid). The shipped examples, the docs, the published schemas and the site editor no longer offer them, and a docs check now holds every node table, including a row that names a family of kinds, to the fields the node catalogue gives those kinds.

Tooling

  • CI builds clean on Rust 1.99, and a failed clippy step no longer skips the test steps after it, so a lint error cannot hide a test failure. The job still fails.
  • CI checks the vendored Instruction Specification against one pinned upstream revision, not upstream's moving main. Both drift checks run by name in CI and in scripts/ci-gate.sh, and a run that skipped fails: a skipped drift test still reports ok. The local gate reads the pin from ci.yml and fails when it cannot reach that revision.
  • agentd-instruction is linted with and without sign, and tested in its default build, in CI and in scripts/ci-gate.sh. Its docs are built with rustdoc warnings denied in both builds, so a broken intra-doc link fails the gate instead of shipping to docs.rs.
  • release_matrix.rs holds the crate README and the corpus UPSTREAM.md to ci.yml's spec pin.

Crates

agentd-mcp 3.0.0 has breaking API changes: McpError::SessionExpired is a new variant, and capabilities() and protocol_version() return owned values, because the negotiated state now belongs to the live connection, which a re-dial replaces. It now needs rmcp ^3.5, connects discover-first (see the P1 section), and HttpTransport::send returns a non-2xx JSON-RPC error body as the response.

agentd-instruction 0.3.0 re-vendors Instruction Specification 345f275 (registry revision 1.1); CI pins that revision. Every source file in it now carries the crate's own MIT OR Apache-2.0 header; seven still said AGPL-3.0-only from before the 0.2.0 relicence. The schema and the whole conformance directory (corpus, refusals, advisories, README, LICENSE) are upstream's bytes. A name is required where the schema's rules require one, not wherever a kind has identity: 1.1 gives every prose kind identity, and an anonymous MUST: stays valid. The conformance runner compares every vendored artifact, and each one passes; a file it does not know how to compare fails as an unknown artifact. Revision 1.1 registers unless and otherwise as structural kinds. A structural block with a body that is not a variant delivers that body unwrapped: a block the registry knows is never dropped from the delivered text while the load reports success.

doc::contains_blocks is replaced by doc::needs_delivery, which is true for anything §3.5 delivery renders, removes or refuses — a block (a leaf of a registered kind included), a keyword, alert or reason line, an inline reference, a column-0 author note, front matter or end matter — and false for plain prose, which delivery would hand back unchanged up to layout (a run of blank lines collapsed, leading and trailing ones dropped). The conformance runner checks that over every line and every document of the vendored corpus.

Instruction refusals carry their Appendix B code; config.invalid text is unchanged. agentd-instruction's Refusal is now {line, code, message} from doc::parse to the API: doc::parse, fold, fold_with_params, fold_full, extract, extract_with_facts and check_grants return or push Vec<Refusal> instead of Vec<String>. message no longer carries the line N: prefix, and Display puts it back, so the printed text is what it was. From<String>, message_body() and the string-parsing path behind them are removed. New CODES lists every code (Appendix B, plus the schema code a fixture pins), and UNDETECTED names the Appendix B codes this crate never emits, with the reason. The refusal fixtures are matched by line, code and message. A reference cycle and unreadable front matter now name a line, as Appendix B and the other ports do: line N: reference cycle: skill/a → skill/b → skill/a (it was reference cycle through skill/a), and line 1 for the front matter.

agentd-instruction reads the rest of the revision 1.1 registry: labels and negated labels, keyword flags, rule strengths, the reason keyword, context keys, label styles, reserved bare names, the sigil table, the wire floor and the revision, and per kind x-alias-of, x-acknowledgement-trigger and x-body-schema (kept as data, never used to refuse), and per attribute its enum and pattern (Registry::attr_rule). Keyword lines are matched by x-grammar.keywordLine: at column 0, with an optional [name] and (if …). In the tree a keyword's flags (SHOULD NOT sets not), its name and its condition are attributes, an alias kind stays as written, a rule's reason is attrs.because, and frontMatter appears only when the document has front matter. Block gains reason and Document gains has_front_matter.

agentd-instruction reads author notes (S9), end matter (S27) and !eval (S23), and enforces attribute values and overrides (S24). doc::Node gains Note and Inert: an unknown bare block, or eval written bare, is an Inert node rather than a Block of prose disposition, and the tree never shows one. Block gains notes; Document gains end_matter, and its source excludes end matter while raw keeps it. New: doc::split_end_matter and SplitEndMatter, doc::reserved_bare_names() and Registry::needs_attrs. tree_json emits endMatter after the blocks when the document has end matter. Two choices differ from the TypeScript reference and are to be raised upstream: an empty :::unless refuses as missing-attribute, the Appendix B condition, where the reference reports schema; and the corpus does not pin the line end-matter-yaml names, which here, as in the reference, is the first end-matter line.

agentd-instruction delivers each block from its tree, in the new private deliver module. doc::fold_full takes (doc, granted, params, facts, resolver): the include depth and the cycle path are its own. The keyword normaliser is gone, and so is the **EXAMPLE literal. InlineSkill.body is the rendered skill prose. Block::delivery_body is what a machinery block's recorded content reads; delivery reads the source. Extraction gains overridden, the rules an override kept from delivery, and unfound_overrides, the document's override targets found nowhere. CODES gains nested-machinery and nesting-depth, this reader's own limits, which Appendix B has no row for; new NON_CATALOGUE lists them with schema (to be raised upstream). A container's child Block now carries its region in the document body (it was (0, 0)), and Block carries the layout of a container's body for delivery, so it can no longer be built outside the crate.

agentd-instruction accounts for the §7.4 resolution manifest (S7) in the delivery walk, and the corpus's manifest.json and manifest.canonical.json are compared: the first by structure and as pretty bytes, the second byte for byte. Manifest is typed — new ParameterUse, Fact, Include and Limits; Authored.version is an Option, and every field §7.4 requires has no default — with signed_form() and canonical(), the RFC 8785 bytes a delivery attestation covers. Digests are in every build: ring is a normal dependency, digest, author_digest, strip_front_matter_signature and front_matter_id moved from sign to the crate root, and sign gates JWS/Ed25519 verification only. The dump example prints canonical too, and takes a corpus case directory, resolving includes as the runner does; its tree mode prints the fixture's tree with members in serde_json's key order, not byte for byte.

The manifest accounts for the delivered text and nothing else. A name the delivered document and an include both substitute is listed once per distinct source and value, sorted by name, then source, then digest, so every value in the text is accounted for. The reference de-duplicates by name per substitution pass and lists such a name twice, and the corpus pins neither; this is decided here and is to be raised upstream for S7. A key compared twice records its latest value, as in the reference. A title is substituted, and accounted for, only where its block delivers it, which is an example, an output or a context; a rule's, an admonition's and a glossary's title is not. An include that degrades to its note, because it does not parse, does not fold or nests past the delivery cap, leaves no entry, bytes or depth in the manifest and nothing its body accounted for. limits.include_bytes counts inlined text only, while the 1 MiB cap still counts every text read. ${name} is scanned by x-grammar.param: a placeholder after a stray ${, as in ${a ${b}, is substituted and accounted for, as the reference's regex finds it, and a test holds the hand-written scan to the vendored grammar.

agentd-instruction reports Appendix C advisories (advise()); agentd does not surface them yet. advise(&Document) returns every Advisory {line, code, severity, message}, sorted by line and then code, for the fourteen Appendix C codes. New Severity (warning, info) and ADVISORY_CODES, the code and severity table, which a test holds to Appendix C. The message carries its line N: prefix, as advisories.json does. Advisories read the document and change neither its parse nor its delivery. The line scans (near-miss keywords, orphan reasons, keywords in examples, wiki-links, sigil schemes and undeclared parameters) read prose only, never front or end matter, fenced code, a machinery body or an author note. The block checks (variant keys and groups, a skill's when, overrides and parameter declarations) apply wherever the block is, inside a machinery body too. Three choices here depart from the reference implementation's advise.ts and are to be raised upstream. First, an author note in a block's or a set's body counts as a note: the line scans skip it, and between two variants it is transparent, as a top-level note is, so a when, a note and an otherwise inside a container are one group with no orphan-otherwise. advise.ts (and the Go and Python ports) scan such a note as prose and report the otherwise as an orphan, though their own delivery keeps the two as one group. Second, a when or unless with several unknown keys reports them in name order, as the Go port does, where advise.ts and the Python port use source order. Third, skill-when-alias also fires on a bare when flag, because a bare flag is read as when=""; the reference ports report only a string value. A section's child Block now carries its region in the document body, as a container's child does. The dump example prints advisories.

Attribute keys are read in lower case, as §3.2 makes canonical (breaking). {Name=x} is name in the tree and the delivery, {name=a Name=b} is refused as repeated-attribute, and :::when{HOST="x"} selects on the host fact. A not-a-key message still quotes the token as it was typed.

Nesting is fence-length containment (§3.3 rule 4; breaking). Inside a block opened with :::, an opener of three colons or more is a line of the body, not a nested block, and the first ::: closes the outer block. Only a longer outer fence (::::) contains a ::: block. Before, any opener nested, so tree, delivery and advisories differed from the reference on such documents. The tree and advisories now match it. Delivery still labels a rule lifted from such a body (**MUST:** q), as the tree says it is one. The reference delivers a kept variant by parsing its body again on its own, where the stray opener runs unclosed and the rule is left unlabelled. This is to be raised upstream with a conformance case. §3.1.1's scanner reads fences the same way.

A front-matter parameter value that is a list is read as the reference's String() renders it, joined by a bare comma ([1, 2] is 1,2). This applies to the default a ${x} resolves to and to the param-default-type message. A form's one of: lists a parameter's values trimmed and joined by , , whether they were written as values="a,b" or as a YAML list.

agentd's §7 verification is the crate's: config::attest re-exports Claims, SPEC_CLAIM, WIRE_FLOOR, Verified, verify, verify_document, admit_family and the digests from agentd-instruction instead of keeping a second copy, and keeps only signing, verify_authored, key files and the freshness helpers. attest::verify_document takes the pinned publisher and max_capabilities rather than an InstructionSource. §7 refusals carry Appendix B codes: sign::verify, verify_author, verify_delivery, verify_document, admit_family and attest::verify_authored return Refusal (no line) instead of String, coded signature-typ-mismatch, audience-mismatch, digest-mismatch, manifest-dropped-missing, unpinned-publisher, delivery-ceiling-exceeded, wire-floor or ungranted-family, and attestation for the §7 conditions Appendix B has no row for (CODES and NON_CATALOGUE gain it; to be raised upstream). The text is what it was, with three exceptions. A delivery whose manifest has no variants.dropped is refused as manifest: variants.dropped is required (§7.4 rule 5) rather than as malformed claims. A JWS segment that is not unpadded base64url is refused as attestation: bad signature base64 (or attestation: bad header base64, attestation: bad claims base64) rather than invalid base64 character; that covers the standard alphabet's + and /, a = and anything after it, and nonzero leftover bits, all of which the crate's decoder used to accept, so a signature segment could be re-spelled and still verify. A header that is not JSON is refused as attestation: malformed header: …. Whitespace inside a JWS, which agentd's copy skipped, is refused.

The daemon's registry read verifies a delivery with the crate's verify_document when its trust pin sets reader. Before, it checked only the delivery's audience, expiry and digest itself, so it also skipped doc against the front-matter id, the manifest's chain to the author signature, and the delivery ceiling within the author's. The effective set is now document_capabilities ∩ max_capabilities ∩ author ∩ delivery, so a delivery that attests fewer families than its author narrows what the read admits, which it did not before. The delivery must embed the author signature the read carries. The author signature is compared with the §7.2 author digest, which excludes the front-matter signature: line, rather than the plain digest, so a registry document that carries its own signature verifies. The read's refusals carry their Appendix B codes, or attestation, and instruction.verified gains capabilities, the set the read admits.

agentd-instruction 0.3.0 has a README: what the crate implements, the vendored revision and how to re-vendor it, the 0.3.0 API, its features, its refusal codes, and how to run conformance.

v1.17.0 — A2A, and nothing beside it

The A2A listener spoke a private dialect beside the specification: an anonymous Pair method that minted credentials, the card served as a JSON-RPC method, 0.3 aliases, -32003 for every refusal, authentication configured under the UI's section, and a display client trusted because it was on loopback. An audit against A2A 1.0.1 found 66 defects, and most of them were that one defect seen from different sides (RFC 0043 maps each to the section that closes it).

The boundary now sits where the specification draws it. agentd serves the 11 A2A 1.0 JSON-RPC methods; GET /.well-known/agent-card.json, public and readable from any origin; three extensions it declares on the card, each active only when a request activates it; one declared custom binding for the unix socket; and an OAuth authorization server on the listener origin. It answers nothing else. The TUI and the web UI are ordinary A2A clients of that surface. agentd tui and agentd ui stay, as a thin launcher.

No agentd-owned name carries a version any more. A version inside a name is a second name for the same thing, and both end up answered forever. An incompatible change takes a new name instead. Versions an external specification requires stay: A2A 1.0, MCP's negotiated revision, JSON-RPC 2.0, the instruction.md spec, and crate semver.

Breaking

The listener:

  • A2A-Version is required. A request without it, or with anything but a 1.0.x, gets -32009 with supportedVersions. a2a-rs clients up to 0.10 send no header, so they are refused.
  • Content-Type: application/json is required (anything else is a bare 415), and so is a JSON-RPC id: a notification is -32600 "A2A requests must carry an id". The envelope is checked in full: jsonrpc: "2.0", a method, a string or integer id, and object params.
  • Only the 11 A2A 1.0 methods and the declared extension methods are routed, matched exactly, with no alias and no prefix strip. Any other name is -32601 for every caller, before authorization. Any other route is 404.
  • An uncredentialed caller gets a 401 with a WWW-Authenticate challenge, and a failed credential gets a 401 with error="invalid_token". A named caller refused a method or an op gets a 403 (-31403 PERMISSION_DENIED). Before, all of these were HTTP 200 with -32003 or -32007.
  • The implicit operator needs a loopback bind with no credential mechanism of any kind, the device grant included. A request that carries Origin, as every browser request does (Origin: null included), is never the implicit operator. A request relayed from 127.0.0.1 to a wildcard listener is never the operator. A TCP listener with no credential now logs a config.warning saying that every local process presenting nothing is the operator.
  • The loopback-origin CORS trust is gone. A browser reaches the listener only from an origin listed exactly in a2a.cors.origins, loopback UIs included. The one exception is the UI agentd ui launches: the launcher admits that origin for its own process. The card GET answers every origin with Access-Control-Allow-Origin: *.
  • Browser tokens live in sessionStorage only, bound to the endpoint they were issued for. A browser always signs in.
  • Activating the command extension (https://agentd.dev/a2a/ext/command) is required. A command needs the URI in A2A-Extensions and in message.extensions, one envelope, no task id of its own choosing, and arguments that the op's published schema accepts. An argument an op does not take is refused. A command refusal's data is [BadRequest, ErrorInfo].
  • Read ops reply with a Message (status, config, workflow.status, subagent.status, plan.get, the introspection reads). They create no task and bind no conversation.
  • Commands block unless returnImmediately is set, as the specification says a send does.
  • Task ids are the server's: UUIDv4. A message names a task only to continue it. An unknown or invisible task is -32001, and a settled one is -32004. A command that names a task is refused.
  • A contextId is its caller's own name. Each principal has its own namespace, so two callers who both say root hold two conversations. An operator addresses conversations by the runtime's key.
  • Push notifications are in the 1.0 shape: authentication: {scheme, credentials} is honoured as registered, the body is a StreamResponse carrying the task (application/a2a+json), Get and Delete need an id, and push config ids are ULIDs. CancelTask on a settled task is -32002.
  • The public card lists no workflows and names no op beyond the static vocabulary. The extended card lists what the caller may run and call.
  • capabilities.extendedAgentCard is false when no scheme is declared, and GetExtendedAgentCard is -32004 there. Reading it needs a credential of a declared scheme. An any-rule caller or the implicit operator gets 401.
  • The card's name and description are agent.name and agent.description, and agent.description is operator-only.

Configuration:

  • a2a.url is an origin (scheme://host[:port], no path, written canonically) and is required for a wildcard bind. It is the card's interface URL and the OAuth issuer.
  • a2a.principals[].id is required for bearer_ref and any rules, and is unique across rules. A certificate names its holder as cn=<CN> or san=<SAN>, so it can never spell a declared id. A rule may set only one matcher. A bearer_ref secret may not equal a2a.bearer, another rule's secret, or start with the session-token prefix.
  • The device grant needs an operator credential (a2a.bearer, or an operator bearer_ref rule) and is refused beside a2a.tls.client_ca and on a unix listener.
  • auth.device.approve requires as. A device principal is user:<name>, and every session approved under one name is one principal. A user-role principal id that a device was approved as is refused at load and at reload, and an approval name that is, or ever was, a rule id is refused.
  • A gate may be addressed only to someone who can see its task. A task is visible only to its owner and to operators, so a to: must be absent (the task's owner answers) or name an operator ({role: operator}, with any labels, or the id operator). Any other to: is refused, naming the principal: at load (exit 2) and at reload for security.policies[].to and a workflow human step, at workflow.create and workflow.update, and when ask_human is called with one. Every operator is addressed as operator, whichever a2a.principals rule admitted it, so a rule's own id is refused too; its labels narrow a gate to it. A to that a step renders (a {{…}} or CEL: anywhere in it) is checked when the step runs. A to: is principal syntax only: a channel (#ops) names no principal and is refused, and the refusal says where a channel can come from — an instruction document (below, under Added).
  • The policy-ask addressee defaults to the operator: an action: ask rule with no to: is addressed to {role: operator}, so the caller whose call is judged does not approve it.
  • An instance-tier spawn to a protected TCP parent needs a {{secret:…}} bearer. An inline a2a.bearer is never handed to a child, so the spawn is refused (instance.spawn.refused) rather than run with its reports refused.
  • Environment variables are read only under AGENTD_*. AGENT_*, SERVE_* and unprefixed spellings are no longer read, SERVE_BEARER, AGENT_A2A_BEARER and AGENT_CONFIG among them. The packaged /etc/default/agentd sets AGENTD_ARGS and AGENTD_*.
  • agentd identifiers carry no version. The extensions are https://agentd.dev/a2a/ext/command, …/events and …/task-annotations, the unix binding is https://agentd.dev/a2a/binding/jsonrpc-unix, and the launch grant type is https://agentd.dev/oauth/grant-type/launch. The config schema's $id is https://agentd.dev/schema/config.json and the workflow schema's is …/workflow.json. A config document has no config_version, and a workflow document has no version.
  • config::v2 is config::settings for embedders.
  • --capabilities drops runtime and surfaces, proc.start drops runtime, config.valid drops its schema marker, the store envelope drops v, and the exit-code and metrics contract-version constants are gone.

Workflows:

  • a2a.wait needs from (addressee syntax: whose reply it waits for). A wait {on: message} without from hears only the run's own principal, an operator or the runtime. conversation is a key, a contextId, or "*" for any.
  • A message wait's step output carries conversation as the runtime's key and contextId as the sender's name, for a2a.wait and wait {on: message} alike.
  • A workflow a2a start may not claim the _instance. prefix or a built-in op's name.

Human-in-the-loop:

  • An owned ask gates on core A2A. An ask backed by a live task a caller owns always gates while the listener serves, whatever the feed switch says. An ask no caller owns gates only with agent.ask_human_unowned: gate, and otherwise takes the fallback.
  • An approved policy gate runs the call it approved, and the asker gets the tool's real result. The answer is {decision: approve|deny}.

The launcher and the display clients:

  • agentd tui and agentd ui are kept, as a thin launcher. The daemon runs with exactly the arguments it was given: the launcher forces no configuration on (put a2a.events.enabled and a2a.introspection.enabled in the config). It never hands a2a.bearer or a resolved secret to the client, and it passes only the documented contract: --endpoint, plus --launch-fd (TUI) or --listen-fd (web UI). The client signs in with a single-use launch code the launcher mints in the daemon's process. The launcher refuses a unix listener, a2a.listen port 0, a2a.tls.client_ca and a non-loopback endpoint before it spawns anything. The daemon's log goes to --daemon-log. --debug and --inline are gone (the daemon answers an unknown argument).
  • The display clients are ordinary A2A clients. They discover the card, send A2A-Version, activate only the extensions the card declares, and fall back to a standards-only mode (ListTasks polling plus SubscribeToTask) when there is no feed or --no-extensions asks for one.

Removed

Named once, as history.

  • JSON-RPC methods: Pair and interface.pair; GetAgentCard, agent/card and agent/getAuthenticatedExtendedCard; every a2a.-prefixed spelling; the 0.3 method names; the bare SubscribeToEvents.
  • Routes: /.well-known/agent.json; agentd-ui's /config.js.
  • Command ops: interface.info, config.set, pairing.code, admin.lameduck, and the argument aliases (workflow.run name, subagent.send text, the reads' id).
  • Feed kinds message, command and pairing, the interface event family, the feed's after cursor and the hello's debug.
  • Config keys: the interface section (interface.enabled, .debug, .display, .origins, .pairing); a2a.principals[].match.aauth_agent; config_version; a workflow's version, start and nodes; the push target's bearer and the 0.3 schemes[].
  • Launcher flags --debug and --inline, and AGENTD_INTERFACE_LOG.
  • agentd-tui's --bearer and --code; the web UI's ?bearer=, its typed bearer field, and credentials in localStorage.
  • --serve-mcp, SERVE_MCP and the other --serve-* spellings; --config-version; the versioned --config-schema selector.
  • The approval spellings await, human, accept_all and yes.
  • The versioned identifiers: config-1.json, workflow-3.json, x-agentd-contract-version, every extension, binding and grant URI with a version segment, interface/v1, and the agent:// alias.
  • The workflow cargo feature: workflows are always compiled.
  • Compatibility code for durable records written by earlier releases.

Each removed key, flag, op, method, feed kind and launcher flag meets its layer's generic refusal: an unknown key, argument, op, method or extension. There are no removal tables and no removed sections in --help.

Security

  • F01: with a2a.tls.client_ca and a2a.bearer both set, any CA-signed certificate that matched no rule was the operator without the bearer being checked. Evidence is now resolved step by step, each kind deciding only for itself: a presented bearer that matches nothing fails, and a certificate is the operator only while no rule exists.
  • F02: grants: ["*"] on a user reached the operator-only ops, and pairing.code minted operator sessions. Every op's authorization floor is consulted before any grant, from one op table, and a grant naming an operator-only op on a non-operator rule is a load error.
  • F03: any named caller could join, read and steer another principal's conversation, the root included, by naming its contextId. contextIds are per principal, a turn a listener message asks for runs only in a conversation its caller owns (turn.refused.not_owner), and message.send from a turn a non-operator drives is held to the same owner rule, so a caller's model cannot message into the root or another principal's conversation.
  • F04: the command ops that name a run or a subagent acted on any id. Each now asks whose it is first, and a non-owner gets the same -32001 as an unknown id. The model's workflow.cancel, workflow.signal and subagent.* tools are held to the same check, and a non-operator's model cannot use the instance-wide workflow tools.
  • F05: status returned every principal's runs, outputs, conversations and subagents to any named caller. It now answers each caller with its own work and the instance's facts, from one visibility rule shared with the feed. The operator's status reports conversation_bindings.
  • F06: agentd ui handed the operator's a2a.bearer to agentd-ui, which served it as a cross-origin-includable /config.js. No credential reaches the display client or the browser now, except a single-use, 60-second launch code minted in the daemon's process and redeemable only from a loopback peer.
  • F07: the web UI sent its bearer to whatever ?endpoint= named, took credentials from the URL and kept them past disconnect. Sessions are bound to their endpoint, kept in the tab, dropped at expiry and revoked on disconnect.
  • F08: pairing was an always-armed 6-digit code behind one global lockout, brute-forceable on a public listener. Sign-in is the RFC 8628 device grant: a 256-bit device code, per-source, per-network and global caps, and an operator who approves each code under a name.
  • F24: missing, invalid and expired credentials get 401 and wrong-role calls 403, each with its challenge.
  • F25: the extended card advertised per-workflow authorization nothing enforced. workflow.run asks the grants and the default start node's roles:, and the card lists exactly what that admits.
  • F52: the public card exposed every workflow and the operator, debug and pairing op inventory. It now carries only what is true for every caller.
  • The instruction freshness watch confirmed nothing (present since 1.0.0). The official MCP SDK's client response cache answered re-reads from memory within the server's ttlMs and replayed an expired entry as a success when the registry was gone, so unavailable: refuse and a pinned source's freeze, drain and exit never fired. The cache is off, and every re-read asks the serving server by mcp://<server>/<uri>. Registry load rises accordingly: refresh is the lever.
  • Every child agentd spawns holds no inherited descriptor it was not handed, embedded in agentd-core or not, and without /proc: the exec tool, subagents, instances and the launcher's client keep their stdio and what they were given. Where close_range is refused and the descriptor limit is above 65536, a child's scan reaches past the highest descriptor /proc/self/fd lists; with no /proc either, the first such spawn reports process.inherited_fds_unmarked.
  • The webhook listener's DNS-rebinding guard accepted any Origin whose host began with 127.. It now accepts only localhost or a loopback IP literal.
  • The auth-failure limiter counts failures, not callers: an origin refusal or an uncredentialed request is never counted, and a live session token is served past the limit.
  • A refused --name=value argument is echoed by name only.

Added

  • The device grant (a2a.device_grant): RFC 8628 device authorization, RFC 6749 token, RFC 7009 revocation and RFC 8414 metadata on the listener origin, declared on the card as an OAuth 2 device-code scheme. The operator ops auth.device.pending, auth.device.approve, auth.device.deny, auth.sessions and auth.sessions.revoke; revocation by session id closes that session's open streams within a tick.
  • Named device identities: a device signs in as user:<name>, so what it owns survives a re-login, and a durable identity registry keeps device names and rule ids apart across restarts.
  • The launch-code sign-in for agentd tui and agentd ui: the TUI reads the code from an inherited fd; the web UI gets it in a URL fragment through a 0600 launch file (or printed with --no-open), and any later tab signs in by a code typed at the launcher's terminal.
  • The events extension (https://agentd.dev/a2a/ext/events): agentd.events/SubscribeToEvents, with a closed vocabulary of kinds, a schema for each, and an auth kind. Switched by a2a.events.enabled.
  • The task-annotations extension (https://agentd.dev/a2a/ext/task-annotations): agentd's facts about a task under that URI, present only when activated.
  • The unix binding https://agentd.dev/a2a/binding/jsonrpc-unix, declared on a unix listener's card.
  • Task.history: the caller's messages and the agent's status messages, in order.
  • Retention (store.retention.tasks {keep_last, ttl}) for settled tasks.
  • observability.status_values: memory keys status publishes as values.
  • a2a.url, a2a.cors.origins, a2a.introspection.enabled, agent.description, agent.ask_human_unowned and security.policies[].to.
  • An instruction document's @human/<name> gate loads, and its channel is announced. A human step a document addresses to one of its ::!humans — top-level, or in an iterate or subgraph body — goes to that human's principal, held to the gate rule (principal=operator loads, principal=user:alice is refused), or, when it names none, to the operators exactly as to: operator. The human's channel is carried on the gate — human.asked's payload.channel and the task's askChannel annotation — for a workflow or a bridge to route the question; it never decides who may answer, and no to: written anywhere else can set one. It is bound to the definition the document loaded: workflow.create and workflow.update cannot write under the document's workflow name (a configured name is refused), and a definition they write under any other name announces nothing. A subagent template's document addresses its gates the same way, so its child loads them, but a template's channels are not announced (the parent's boot warns). The spec's own conformance examples, which address their gates this way, now load; the instruction crate's fold and its corpus are unchanged.
  • admin.set {path, value}, an operator op over the runtime-settable paths.
  • agentd --extensions and agentd --extension-schema <name>, the registry and schema bundles that agentd.dev serves at each extension's URI.
  • ListTasks honours every field it is sent (filters, paging with an opaque cursor, historyLength, includeArtifacts), and ListTaskPushNotificationConfigs pages.
  • The launcher's --daemon-log, and agentd ui's --port and --no-open.
  • agentd-tui's --launch-fd, --login [--scope user|operator], --bearer-file and --no-extensions; agentd-ui's --listen-fd.

Fixed

The protocol findings of the audit:

  • C1: SendMessage took a caller-chosen task id, reopened settled tasks and started a new task for another principal's id.
  • C2: agentd's outbound send used the 0.3 role spelling, so every 1.0 server, agentd's own included, refused it.
  • C3: the server ignored every ListTasks field and did not sort.
  • C4: push config Get and Delete without an id read an arbitrary config or deleted every one.
  • C5: an inline push config on SendMessage always failed for a new task.
  • C6: the conformance check for a disclaimed capability called a 0.3 method name and passed on any server.
  • C7: parts other than text or a command were refused or dropped; -32005 is now used for an unsupported media type.
  • C8: outbound delegation never read the peer's card and always streamed.
  • C9: the display clients had no standards-only mode.
  • F09: push credentials in the 1.0 shape were dropped.
  • F10: Pair was an undeclared, anonymous method that issued credentials.
  • F11: GetAgentCard was a non-spec method and the clients' only discovery.
  • F12: extension activation had no effect on the server.
  • F13: the command extension answered sends with results that were neither a Task nor a Message.
  • F14: display-only ops sat under the generic command extension.
  • F15: the card declared protocolVersion 0.3.0 for a 1.0 interface.
  • F16: there was no A2A-Version negotiation on either side.
  • F17: the card declared no security schemes.
  • F18: the interface URL was the raw bind address.
  • F19: human-in-the-loop gates worked only with the UI flag on.
  • F20: sends used the 0.3 configuration.blocking.
  • F21: client messages omitted Message.role and could pair a contextId with another context's task.
  • F22: the display clients did no card discovery.
  • F23: the display client used agentd's extensions without checking the card.
  • F26: unknown methods got -32003 instead of -32601.
  • F27: legacy method aliases and the a2a. prefix were served.
  • F28: the guard over non-spec methods could not see the listener's own vocabulary.
  • F29: the A2A-Extensions echo misreported what was activated.
  • F30: CORS blocked A2A-Version and discovery, hid the echo, reflected any origin and trusted every loopback origin.
  • F31: the well-known card answered 200 with null or an internal error, with no validators.
  • F32: skills were emitted without their required description.
  • F33: the command extension under-declared its op space.
  • F34: the extension specs did not define what the extensions carry, and the URIs 404'd.
  • F35: clients depended on undeclared internal payload shapes.
  • F36: the push body was a bare Task.
  • F37: rate-limit refusals came back as success, draining had two codes, and CancelTask on a settled task returned it.
  • F38: SSE streams closed early, framed errors as SSE on 200, and SubscribeToTask on a settled task opened a stream.
  • F39: listener authentication and CORS were configured under the UI section.
  • F40: the daemon owned the display clients' chrome layout.
  • F41: the transcript reached clients only through a private feed.
  • F42: the clients' observation created a durable task on every poll.
  • F43: introspection, the log ring and runtime approval were gated on UI flags.
  • F44: the client-daemon contract test ran only on interface changes and asserted internals.
  • F46: the client's ListTasks ignored paging and never asked for artifacts.
  • F47: the TS SSE parser handled only LF framing.
  • F48: the TS client dropped error information and retried auth and version failures forever.
  • F49: client auth ignored the card's schemes and session expiry.
  • F50: the 0.2 discovery path was still routed.
  • F51: the docs and comments contradicted the code on what is A2A.
  • F53: the card name ignored agent.name.
  • F54: malformed envelopes got -32700 or -32003 instead of -32600.
  • F55: the feed resumed from the highest seen seq instead of the goodbye's.
  • F56: the core audit path hard-coded one UI's polling.
  • F57: the daemon CLI embedded the Node clients' flags and forced UI config.
  • F58: the client hard-coded agentd configuration knowledge.

Also fixed on the way: a tool's _meta reaches an MCP server in params._meta (it was sent inside arguments, or not at all); a call's bound is honoured; the MCP notification stream carries its headers; operator headers go out once per POST; a slow or abandoned tools/call no longer holds up the next call on the same server.

  • Two workflows with one name are refused, naming both sources. A dir: or file: workflow sharing its name with another, or with an instruction document's :::!workflow, silently replaced it at load. Startup now exits 2 and a reload is refused with nothing applied: a reload now reads and parses its workflow definitions before applying any section, where one that failed to load used to be refused after the new instruction, MCP servers, tools and skills were already live. A runtime-stored definition that a configured one of the same name keeps from loading is logged as workflow.stored.shadowed instead of being skipped silently, and discarded.
  • A reload applies completely or not at all. Three checks could only run once a reload's new MCP servers and tool registry were live — a workflow step naming a tool or server they lack, a uri: workflow document read through a server the same reload adds, and the registry build itself — so a reload they refused left the settings, instruction, MCP connections, registry and skills on the refused configuration and kept only the running workflows. A reload is now staged beside the running state: added and changed servers are dialed into a connection set of their own (an unchanged server keeps its connection), and the registry, the workflows and their references, the skills, a resource instruction and the intelligence token are built and checked against it — and so are the listener's principal rules and webhook routes, which used to be rebuilt after everything else had switched, so rules or routes that failed to build were logged and skipped while the reload reported applied and the settings held the new rules. Any failure refuses the reload with nothing changed and closes what it dialed (mcp.disconnect, reason: "reload refused"); otherwise everything is switched in at once, with nothing left that can fail. A server counts as unchanged only when everything its dial reads is — endpoint, headers, auth, oauth, aauth, service, rate and timeout; comparing the endpoint, headers and aauth alone kept the old connection, and its old credential, when a credential was rotated or revoked, while the reload reported mcp. A re-dialed server is subscribed again for what the old connection carried: the subscribe starts of unchanged workflows, suspended wait on: resource steps and an unchanged resource instruction, which is read again through the new connection (all three silently stopped firing); a reload removing that instruction's server is refused, as a start is. A definition the agent stored (workflow.create/update) can no longer refuse the operator's reload or start: one naming a tool or server the reload takes away kept the tool enabled and the server connected; it is now left out, logged workflow.stored.invalid, kept in the store, and loads again once what it names is back — and the tools refuse to store a definition naming a tool, server or stream the agent does not have. A replaced workflow whose schedule node is unchanged keeps its next deadline (every edit pushed it back a full period). A tool step naming a workflow's tool is refused on every path (a reload that kept the registry accepted what a start refused), and a reload dials no endpoint closed egress does not admit. On the way: the workflows are re-checked whenever mcp, tools, knowledge or search change, so removing a server a workflow calls is refused rather than applied; a resource instruction served by a server the same reload adds is read through it (it was read first, failed, and the old text stayed while the reload reported agent.instruction), and one nothing serves refuses the reload, as does an unreadable intelligence.token_file (the new endpoint went live with the old credential); a rebuilt registry keeps the configuration's workflow tools (it dropped every one); a workflow a reload adds or changes has its schedule, loop and subscribe starts armed (an added schedule never fired); a workflow document that resolves to something other than a mapping is refused naming its source (it panicked the daemon); and a failed url: workflow source is named by scheme, host and path in the log and the refusal, with the fetch error scrubbed of the URL's userinfo, query and fragment (each on its own too, and a userinfo holding a raw ? or # whole). A run in flight on a server the reload removes finishes the call it already made; its next step on that server fails as not connected.
  • A configured workflow cannot be edited or deleted at runtime. workflow.update of a workflow the configuration defines (inline, file:, dir:, url:, uri:, an instruction document's :::!workflow or :::!config workflows, a subagent template's) was stored and reported as success, and the next restart or workflow reload put the configured definition back; workflow.delete of one came back the same way. Both, and workflow.create of such a name, are now refused, naming the source to change (workflow.refused in the log; a url: source is named by scheme, host and path, never its userinfo or query). A workflow created at runtime stays editable; when a reload defines its name in the configuration, the configured one loads, the stored one is discarded (logged, with its hash, as workflow.stored.shadowed) and the name is refused from then on, and when the configuration drops a name it is free to create again. A stored definition is never armed in place of a workflow the configuration stopped defining.
  • An exec workflow step keeps its command's exit status. The daemon's reactor reaps every exited child in the process, and the exec runner polled its own child with try_wait: when a tick landed between the command's exit and the poll, the step failed with wait <cmd>: No child processes and no exit_code. The command is now spawned through the reaper's pid registry, so its status reaches the runner whichever side collects it, and a timed-out command is still killed and reaped.
  • A delegation whose deadline runs out mid-call reports a timeout. An outbound A2A call whose deadline expired while a GetTask read was in flight failed with Resource temporarily unavailable (os error 11): Linux reports an expired socket read timeout as EAGAIN. Both kinds now read as timed out waiting for the peer.

Known limitations

  • A tools/call that agentd abandons at its bound (a step timeout, a lease bound, a turn deadline) returns promptly but sends no notifications/cancelled: rmcp 3.1.2's call_tool hides the request id and its input_required retry helper is private. The server may keep running the side effect, and a retry under the same idempotency key can race it. Fixing it needs an rmcp change, or agentd driving the rounds itself.

Tooling

  • cargo deny check passes and CI runs it (a deny job, and a step in scripts/ci-gate.sh): ISC is allowed (ring, rustls-webpki, untrusted), and AGPL-3.0-only is excepted for agentd's own five crates by name, not allowed for dependencies. It judges the graph the release ships: deny.toml's [graph] features is release.yml's FEATURES (default features on), and a test fails if the two differ. That graph adds Zlib (foldhash, zlib-rs, both in the a2a stack), now allowed, and paste (RUSTSEC-2024-0436, unmaintained, a compile-time proc-macro through cel-interpreter with no known vulnerability), ignored with that reason until cel-interpreter moves off it. Nothing else is waived.
  • scripts/ci-gate.sh dry-runs every unpublished crate together in one cargo publish, so a crate whose sibling dependency bumped in the same release (cli on core, core on mcp, mcp on net) is packaged and verified before the tag instead of after its dependency reaches crates.io.

Crates

agentd-mcp and agentd-net have breaking API removals: the vsock transport, the MCP server, the protocol version and era model, the native client and the NDJSON framing in agentd-mcp, and the vsock feature and public validate_ca_pem in agentd-net. The release bumps both to 2.0.0.

v1.16.0 — a document configures what it is, not where it runs

v1.12.1 established that a served document may not configure the terms it is judged by. This release is the mechanism behind that sentence, because the enforcement was a list of six path prefixes and the settings schema has 143 paths — so the rule held for the six things somebody had thought of, and everything else was writable by an unsigned, unpinned document with no capability grant at all.

The boundary is now an allow-list, and the question it answers is whether a setting describes the AGENT or the DEPLOYMENT it runs in. A path in neither list is refused, and a test walks the generated schema to make sure there are none.

Security (breaking)

  • A served document may only configure what it IS. The boundary between a document's machinery and the operator's configuration was a deny-list of six path prefixes — agent.document_capabilities, agent.instruction, security, identity and two retired spellings — and the settings schema has 143 paths. Everything nobody had thought about was writable by an unsigned, unpinned instruction with no capability grant at all, because :::!config is family core, below the trust ladder entirely.

    Five of those were exploitable, and each was reproduced before it was closed:

    • services is the catalogue security.egress: closed is checked against. A document catalogued its own destination and the closed-egress sweep passed it — the gate still ran, and the allow-list it reads had become the gated party's to write. The pairing is deliberate: mcp.servers stays a document's to declare, services does not. The document says what it needs; the operator says what is reachable.
    • agent.tools — fragments merge deep and arrays CONCATENATE, document entries first, so an operator who narrowed internal: to [think, finish] got [exec, subagent.run, think, finish] once the document asked.
    • intelligence.endpoints — same concatenation, so the document's endpoint landed ahead of the operator's and became the primary: the whole conversation, system prompt and tool results included.
    • a2a.principals — any → user with grants: ["*"] admitted anonymous callers to every non-admin command. (any → operator was already refused by a validation check, and the admin family answers to the role regardless.)
    • interface.enabled and webhooks.listen — the human control plane and an inbound socket of the document's choosing.

    The boundary is now an allow-list: DOCUMENT_MAY_WRITE names what describes the agent (its workflows, streams, MCP servers, A2A peers, context, goal, limits, model choice, loop shape, and the two tools keys that only ever narrow), OPERATOR_ONLY names what describes the deployment, and a path in neither is refused. The polarity is the fix, not the list contents: a deny-list can only refuse what somebody already thought of, which is precisely how all five arose.

  • A subagent template's machinery faces the same boundary. It had a seven-key list of its own that never consulted the document rule, so the boundary was reachable in one hop: the identical :::!config that is refused in the agent's own document — identity.autonomous_as, agent.document_capabilities, agent.instruction.trust — was accepted inside a template and the child was spawned with it. One classifier now answers wherever a served document contributes configuration, and it subsumes the old list. A child keeps ONE narrowing of its own, and it is a different rule rather than the same one twice: a2a: and intelligence: are a document's to write at large but a child's are overwritten wholesale at spawn, so a template writing them is refused instead of silently ignored — which had been true of intelligence: all along. A child's service catalogue is also assigned unconditionally: a parent with none must hand the child an empty one rather than leave whatever the template wrote.

Added

  • every_config_path_is_classified_for_documents — the forcing function, and the reason the two lists above will still be true next release. It walks the generated settings schema and fails on any path in neither list, the same shape every_config_path_is_classified has held the reload partition to since three reload defects turned out to be unclassified fields. Both checks now share one schema walk, because "what is the config surface?" has one answer.

v1.15.0 — nothing claims more than it does

One subject, reached from several directions: the distance between what agentd said and what agentd did. A card carrying four fields the protocol has no room for. A routing key that never routed. A rate card nothing multiplied. A metric field nothing recorded. A budget that could refuse forever and end nothing. A conformance oracle whose independence had quietly become circular. Each is either removed or made true — and where a claim is worth keeping, it is now DERIVED from the thing it describes instead of maintained beside it.

Changed (breaking)

  • The agent card is what the A2A library emits. It carried four fields the protocol's own message does not have — protocolVersion, url and preferredTransport (the pre-supportedInterfaces card spelling) and a stateTransitionHistory capability — so every typed reader silently dropped them, and that loss was the stated reason for hand-serving the card instead of passing it to the SDK. Nothing in the repository read any of the four.

    The card is now built and then round-tripped through a2a_rs's generated AgentCard, so the wire form is the one the A2A protobuf defines rather than our rendering of it. That also makes it canonical proto3 JSON: required: false on an extension is now ABSENT, which is what a conformant peer emits and reads. supportsAuthenticatedExtendedCard becomes the message's own capabilities.extendedAgentCard. A unit test pins the card as a fixpoint of the SDK's type — parse, re-serialize, compare — which is the mechanical form of "we do not invent card fields".

  • A spent lifetime token budget now ends the instance — intelligence.budget.lifetime_exhausted, defaulting to drain. Before this, a daemon whose lifetime_tokens were spent sat there refusing every turn forever: admission failed, turns dropped, steps failed, and nothing acted on it, so the only warning was a gauge somebody had to be watching. A budget that cannot end anything is not a budget.

    drain finishes live work and exits 0, which is what a lifetime ceiling is usually for — an orchestrator restarts the instance with a fresh window. refuse keeps the old behaviour for an operator who would rather inspect a stopped instance than lose it, and exit stops now with exit 7. The transition is one budget.lifetime_exhausted line carrying the policy that applied, emitted once rather than once per refused admission.

    This is the vocabulary agent.instruction.unavailable already uses for the other "something this agent depends on ran out" question, minus the two words that cannot apply: you cannot keep spending, and freeze is indistinguishable from refuse when live work needs tokens too. Internally the lifetime ceiling answers Admission::Exhausted rather than Fail, because it is not a property of the unit that tripped it — no later unit can succeed either.

Security

  • security.egress: closed now covers the instruction fetch. An url: or oci: instruction is resolved BEFORE typed deserialization — its machinery has to fold into the configuration being built — so the boot sweep in validate(), which walks MCP, intelligence, peers, the store and workflow refs, structurally could not see it. closed meant closed everywhere except for the document that becomes the agent's own standing policy: agentd would dial an uncatalogued host to fetch it.

    The policy is now read off the raw document once, before anything dials, and covers agent.instruction, agent.prompt and subagent templates alike. It fails CLOSED on a config it cannot understand — an unparseable security.egress is treated as closed, an unparseable services: map as empty — so a typo in the catalog refuses the dial instead of waving it through, and the typed parse reports the real error a moment later.

Changed

  • examples/mcp-servers.json is now examples/mcp-servers.fragment.json, and carries no config_version. It was never a runnable config — it declares mcp.servers and nothing else, no instruction and no intelligence endpoint — but it looked like one, and SAMPLES.md told readers to "load it with --config". It is an inventory to layer under a config with a second -c, which is the shape examples/startup/ already uses for its catalogue, and it now says so.

    Its four servers hold all three trifecta legs deliberately, so merging the whole file into one agent is refused. That refusal is the lesson the sample exists to teach, and a test now pins it — along with the fragment carrying no config_version, and the detection fix above. .json examples were not covered by the example sweep at all before this.

  • docs/security.md cites symbols, not line numbers. All 83 file.rs:NNN anchors became file.rs::symbol, because ten of them broke during this very session as unrelated edits shifted the lines beneath them.

  • One dependency-count table. It lived in both docs/architecture.md and docs/why-rust.md and they disagreed; why-rust.md is now authoritative, with the counting convention stated beside the numbers, and architecture.md links to it. A number that appears once cannot disagree with itself.

Removed

  • crates/a2a-oracle. It booted the daemon and re-parsed every response with a2a-rs to prove "two independent readings agree". That was true and valuable while the A2A server was hand-written; it stopped being either when the listener BECAME a2a-rs's JSON-RPC adapter, because the same generated types then sat on both ends of the round trip and agreed by construction. Its README still opened "agentd's A2A server is hand-written".

    What it actually proved that nothing else did — that our method names and error codes are the SDK's own constants — needed no daemon at all and is now a unit test, strengthened to check both directions: a method we invented fails, and so does a spec method we forgot to answer. The live error-code behaviour it also covered was already asserted by a2a-conversation/protocol-errors-use-the-specified-codes. Removing it takes a whole crate, a CI job and ~180 build dependencies with it.

  • intelligence.models.<tier>.service. It named a kind: intelligence catalogue entry, was checked at startup, and was then ignored: every tier's call went to intelligence.endpoints regardless. A config reading "this tier talks to my on-prem gateway" talked to the shared one — a routing key that does not route, which is the worst kind of security-adjacent lie because it reads like a control. Refused by name; the sibling model, window and fallback all have real consumers and stay.

  • intelligence.pricing and a tier's pricing:. They parsed, validated, published a shape into the JSON schema for editor autocomplete — and nothing ever 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. An operator writing their rate card into the config could reasonably expect a spend figure back out; there was none. Real cost accounting is a feature worth designing (cached versus fresh tokens, tier rollups, what a currency budget does when a provider reprices mid-run), not a field left half-declared.

  • emit: {metric: …}. The field validated, ran green and recorded nothing: no code path ever read it. An accepted field that does nothing is worse than a refused one, because the operator believes it worked.

  • AliasKind::SetFromFile. No alias used it, so the arm was unreachable; the envelope-armoring it performed is done for real in from_document.

  • sdk/typescript/. Nothing in the repository referenced it — no build, no test, no publish workflow, no documentation. It declared version 1.3.0 against a 1.14.1 runtime, emitted type: values absent from the node registry, and targeted a TOML dialect the loader has never read.

  • ink-text-input from the interface's dependencies, and the 'spawning' subagent status from four client call sites. No daemon ever sends spawning: a record is built with spawned and always overwritten with running or failed before the insert. Deleted rather than remapped to retiring, which would newly treat a retiring child as live and permit messaging and stopping it.

Fixed

  • Three MCP methods moved onto the official SDK. prompts/get, completion/complete and resources/templates/list were the last operations agentd reached over its own hand-rolled JSON-RPC path while every other call went through rmcp. They now call the SDK, which also means the completion ref is type-checked against the protocol's tagged union before it reaches the wire, and resource-template pagination is the SDK's cursor walk rather than ours.

  • agentd --config-schema=1 no longer walks the config-discovery chain. is_informational listed --config-schema=2, a spelling the argument loop refuses outright, and omitted =1, the one it accepts — so a malformed agentd.yml in the working directory could break a purely informational invocation, which is the exact failure that function exists to prevent.

  • One heartbeat, one liveness verdict. The --health-file writer took a hardcoded 10s staleness window while /healthz judged the same heartbeat at LIVENESS_STALE_AFTER_MS (5s), so a wedged reactor read unhealthy on the endpoint and alive: true in the file for five seconds. The writer now takes the constant.

  • --capabilities reports the methods the listener actually serves. The manifest's a2a.methods was a fourth hand-maintained copy and had drifted: it omitted GetExtendedAgentCard and the four push-notification-config methods, and listed GetAgentCard, which the dispatch table does not carry. It is now derived from METHODS plus the new LOCAL_METHODS — the two calls answered ahead of the dispatch table (GetAgentCard, Pair), which were served and named nowhere.

  • The interface ops are advertised. config.set, subagent.get and pairing.code were dispatched by the listener but absent from command_ops_of, so the agent card's skills, the command extension's params.ops and --capabilities all under-reported the served surface — the drift the "one list feeds three views" invariant exists to prevent. interface.info now reads that same list instead of keeping a fifth copy.

  • The retired flat schema is #[cfg(test)], so the compiler enforces what a comment used to claim. ConfigFile, config_schema, paths::bindings and the four no-arg wrappers over them (read_documents, env_document, resolve_flag, help_section) were reachable from production and reached only from tests — the module header said so in prose, which is not a mechanism. They are now gated, and referencing one from production fails to compile. They stay because they are a genuinely useful FIXTURE: a small, stable schema for exercising merge semantics and the binding walk without pinning those tests to the real settings schema, which changes every release.

  • A document made of one v2 section is a v2 document. The schema-detection key list was a hand-maintained subset and had fallen seven sections behind — mcp, services, identity, interface, goal, subagents and webhooks were all absent — so a -c overlay carrying only mcp:, an ordinary layer in the config chain, was refused as "the flat schema, which agentd does not accept". Doubly wrong: the document is not flat, and the retired schema had nothing to do with it. The list is now derived from the settings schema, so a section added tomorrow is detected tomorrow.

v1.14.1 — the image ships what the binaries ship

Fixed

  • The published container was built with a narrower feature set than the release's binaries. release.yml builds the standalone binaries from its FEATURES list and left the container job to the Dockerfile's own ARG FEATURES default — which never gained sign, oci or decrypt. So ghcr.io/agentd-dev/agentd could not verify an instruction signature, pull an oci:// instruction, or open an encrypted envelope, while the binary beside it could and the release notes said both could. Verified by running the published 1.14.0 image: agent.instruction.trust came back "this build cannot verify signatures".

    The workflow now passes FEATURES explicitly rather than trusting a default to stay in step, and the Dockerfile default matches. Two artifacts of one release have one capability surface.

  • Every documented copy of the feature set was a release behind — the README (twice), docs/deployment.md, docs/architecture.md and the Dockerfile's own header. docs/deployment.md's table now describes cel, sign, oci and decrypt as well.

  • Two more rows in release_matrix.rs, since a comment is not a mechanism: the Dockerfile's ARG FEATURES must equal release.yml's, the container job must pass it as a build-arg, and any list in those four documents that names itself as the shipped set (it names both aauth and oauth) must be current.

v1.14.0 — who wrote it, who pushed it, and both keep being checked

Two independent questions about the document an agent takes its policy from — who authored it, and who published the artifact carrying it — each with its own answer, and neither of them stopping at startup.

Changed (breaking)

  • The operator admin family is now command ops, not custom JSON-RPC methods. admin.drain, admin.lameduck, admin.pause, admin.resume and admin.cancel are invoked as an ordinary A2A SendMessage carrying a command DataPart, the way every other agentd operation already was — so a peer holding a conformant A2A client can drain an instance without knowing anything agentd-specific. The reply is a Task, which is the protocol's model for work.

    The old a2a.drain / a2a.pause / … methods are removed, not deprecated: a call gets -32601 and the code that served them is deleted. They were never A2A methods, so no conformant peer could reach them; keeping a second spelling alive would only have delayed the day somebody depended on it. agentd's own TypeScript client moved with them.

    The admin family answers to the ROLE alone: an explicit grants: entry does not reach it, not even grants: ["*"], which preserves exactly what the method-level check gave before.

Security

  • A pinned publisher is now verified on every transport, not just MCP. The §7 author signature travels inside the document as a front-matter signature: line — author_digest has always excluded that line so it could — and it is checked at the one point every source converges on: after decryption, before anything interprets the bytes. A file:, dir:, url: or oci: instruction now gets the same authorship guarantee a registry read had.

    With a publisher pinned, an unsigned document is REFUSED, as is one signed by another publisher or naming a doc id no pin covers — matching on the document's own id alone would let an attacker dodge every pin by deleting a line. The attested capabilities cap the grant exactly as they do on the wire (§7.6 step 5): grant ∩ ceiling ∩ attested.

    A folder is verified per file, before combining: the combination carries no single signature, since a later document's front matter is dropped when they join. A pin whose author_keys are all registry JWKS URIs cannot be resolved by a local load, and falls to agent.instruction.unenforceable.

    This wires up attest::verify_document's sibling path — the verification code was written, tested and exercised only by an example.

  • OCI artifacts can require a cosign signature — oci: {ref, cosign_key}. The pull fetches the signature cosign stores beside the artifact (the sha256-....sig tag), verifies it against the configured public key (PEM SPKI, P-256 or Ed25519), and checks that the signed payload names THIS manifest digest — so a valid signature over a different artifact is refused, as is an unsigned one. The freshness re-pull applies the same check, because a moved tag is a new artifact.

    It answers a different question from trust: who PUSHED the artifact, versus who WROTE the document. Public-key only — keyless would add a Fulcio chain and a Rekor lookup to the startup path; the verification reuses the ring the oci feature already builds, so no new dependency.

    cosign_key is not special to agent.instruction: agent.prompt and a subagent template's instruction resolve through the same code and take the same oci: {ref, cosign_key}.

  • A trust pin keeps applying after startup. The §7.7 freshness watch re-pulls an oci: instruction on a cadence, and adopted whatever came back: the author signature was checked at config load and never again. A pin exists precisely because a document can be swapped under a RUNNING agent, so verifying only at boot left the hole open. Every re-pull now verifies against the same pins, caps capabilities by what the NEW document attests (a re-pulled document that attests fewer families gets fewer), and refuses by keeping the running instruction rather than adopting an unverified one.

  • unavailable: auto freezes a pinned OCI source, as documented. The pinned/unpinned decision matched the pin's uri against the source's — which works for a registry read, where both name the same document, and never matches an oci://, file: or url: source, whose signature travels inside the document instead. So auto silently read a pinned artifact as unpinned and chose keep where the docs promise freeze.

  • One InstructionSource. The trust-pin type existed twice, once in the config surface and once in attest; they were the same fields with the same meaning, which is two places to update and one to forget.

Removed

  • Every legacy spelling. No deprecation windows anywhere in the config or CLI surface: each of these is refused by name, pointing at its replacement, and the code that served it is deleted.

    GoneUse
    --instruction-file--instruction.file (or --instruction <PATH>)
    --prompt-file--prompt.file (or --prompt <PATH>)
    top-level instruction: {decrypt}agent.instruction.decrypt
    a workflow entry's flat glob: beside dir:dir: {path, glob, order}
  • The superseded config loader. config::Config — the pre-config_version loader with its own load/reload/validate/effective_view and its own RESTART_ONLY_FIELDS list — is deleted, about 4,300 lines. config::v2's Settings has been the only loader the runtime calls for several releases; the old type stayed compiled, and every guardrail test that walks the config surface had to walk it twice. Two answers to "is this field reloadable?" is one answer too many.

    Dead code went with them: principals::is_admin and principals::bare (the compiler found the second), the bare drain/pause/cancel match arms that only the removed methods could reach, the operator pre-check in the listener, and the manifest's deprecated-methods field. maybe_decrypt now takes the recipient keys rather than a config section that no longer exists.

Added

  • A2A extensions, declared and negotiated the way the specification provides. Everything agentd speaks beyond the eleven core methods is now an AgentExtension on the card, with a versioned URI, a description and required: false: https://agentd.dev/a2a/ext/command/v1 (the command ops) and https://agentd.dev/a2a/ext/interface/v1 (SubscribeToEvents). The earlier urn:agentd:interface spelling is gone with everything else.

  • The A2A-Extensions handshake. A client lists the URIs it means to activate; the response echoes the ones actually activated. Unknown URIs are dropped rather than refused, and no extension is required, so a client that sends no header still gets a complete service. The header is allowed in the CORS preflight and exposed on the response, so a browser client can use it.

  • Command ops are advertised as skills. GetAgentCard lists them; GetExtendedAgentCard narrows the list to what the CALLING principal may run — so "which operations may I use" is answered by A2A's own discovery.

  • docs/a2a-extensions.md — the whole subject in one page: the spec's rules, what agentd declares and why, the handshake, the DataPart shape with the full op table, the one-line migration off the removed methods, and the three checks that keep the claim true.

Fixed

  • The built-in command ops are a reserved namespace. A workflow's a2a start node could declare any command name, and a declared command takes the durable-inbox path — which does not run the per-op authorization the built-ins carry. The dispatch comment claimed "a built-in wins"; the code checked the declaration first. Harmless while the built-ins were reads like status; not harmless once admin.* joined them, where a collision would shadow an operator's drain control with a run anyone the start node admits could fire — and where the model may create workflows, that author is the model. Now refused at validation (config load and workflow.create), and the listener dispatches a built-in to its own handler regardless.

  • CI now lints the feature set the release actually ships. One matrix row was commented "the release-artifact set, exactly" and had not been: sign, oci and decrypt joined the release long after it was written, so the combination that ships was the one combination nothing linted, and neither was any of the three on its own. Both gaps produced -D warnings breaks in this cycle. The row is corrected, the three have solo rows, and release_matrix.rs now derives the requirement from release.yml — CI and the local gate cannot drift from the release, or from each other, without a test failing.

  • The shipped examples load again, and a test says so from now on. examples/instruction-registry-consumer.yaml still wrote instruction_sources: at the top level, and four runner scripts plus the systemd unit still passed --instruction-file — both refused by name since the rename, so copying any of them produced an immediate exit 2. examples/ is documentation people copy, and nothing in the tree checked it: now every example that declares config_version goes through --validate-config, and every shipped script and unit is scanned for a flag the CLI refuses.

  • An outbound command announces the extension it uses. A peer call carrying a command DataPart now sends A2A-Extensions, on both the streaming and non-streaming paths, so the peer can confirm activation instead of inferring the vocabulary. agentd's TypeScript client does the same — on a command DataPart and on SubscribeToEvents, and on nothing else, since a plain conversational send uses no extension.

  • The capabilities manifest and the agent card cannot disagree. Both, and the extension declaration, now read one list of ops (command_ops_of) and one list of extensions (extensions_of). The manifest previously carried its own copy of the ops, which had already drifted — it never mentioned the admin family — and it advertised the interface extension unconditionally while the card correctly withheld it when interface.enabled was off. A peer reads the card and a controller reads the manifest; the two must say the same thing.

v1.13.0 — trust lives with the instruction it protects

Everything about an agent's instruction now sits under agent.instruction.*, including who may sign it — and a setting that cannot enforce anything says so.

Changed

  • The operator-only config surface is enforced by path. A document's :::!config may not write agent.document_capabilities, agent.instruction.*, security.*, identity.*, instruction.* or the pre-1.13 instruction_sources; the check now matches wherever a document spells one of them, since fragments merge deep. Documents still configure what is not operator-only — store, limits, lifecycle, workflows. (The same check shipped in v1.12.1; this release extends it to agent.instruction.trust.)

Changed (breaking)

  • instruction_sources is now agent.instruction.trust. Everything else about the instruction already lived under agent.instruction.*, and this was the odd one out — worse, it was split: freshness in the pin is the same clock refresh sets, and being pinned is what makes unavailable: auto mean freeze. It is deliberately not called sources: file, dir, url, oci and mcp are the sources, while these say who may sign what they serve. The old top-level spelling is refused by name.

    It stays restart-only inside an otherwise reloadable setting — a revoked publisher must not survive a hot reload — and that arrangement is now declared in RESTART_ONLY_WITHIN_RELOADABLE and checked by the classification guardrail, which matches by prefix and would otherwise have read the subtree as reloadable.

Added

  • agent.instruction.unenforceable — warn (default), refuse, or ignore. Signature verification runs on the registry read path only, so a pin beside a file:/dir:/url:/oci: instruction, or one naming a document this agent never reads, enforces nothing. Startup now says so, and an operator who wants that fatal can have it.

v1.12.1 — operator configuration is the operator's alone

Changed

  • A document's :::!config may not write operator configuration, checked by path: agent.document_capabilities, agent.instruction.*, security.*, identity.*, instruction.* and instruction_sources. A fragment merges deep into the configuration, so the rule now matches wherever a document spells one of those settings rather than only at the top level. Writing one is refused at load, naming what the document wrote.

    Documents continue to configure what is not operator-only — store, limits, lifecycle, workflows, MCP servers, streams — which is what the directive feature is for. Upgrading is recommended for anyone loading an instruction document from a source they do not control.

v1.12.0 — what am I running, and who said so

Two things an operator meets before anything else in agentd: getting it installed, and working out what a config actually does. Both got in the way.

Added

  • --effective-config: what am I running, and who said so. One JSON object on stdout — the assembled document, the layer that set each setting (file ./agentd.local.yml, env, flag, the conventional folder, the generator), the :::!config fragment the instruction declared, and the files that were loaded. --validate-config answers "is this valid"; nothing answered "what is it". A running config is now assembled from up to three discovery rungs, the environment, the flags, three conventional folders and a fragment inside an instruction that may itself be a folder, an artifact or a URL — that question got hard to answer by reading.

    It runs on an INVALID config on purpose (the moment it is most wanted), reporting the errors on stderr beside the document, and exits 0 because a report is not a verdict. Values under a credential-shaped key are redacted; a {{secret:…}} reference is shown as written. The provenance credits the layer that CHANGED a value, not a later one that restated it — naming the restater would send an operator to edit the wrong file. Documented in configuration.md §10a.

Tests

  • The surfaces v1.11.0 changed but did not test. Auditing the release rather than waiting for a failure turned up four: no guardrail tied an alias to a schema path (the agent.instruction / agent.prompt alias arms merge a patch directly, so a path that moved — as glob and order did — would write a key nothing reads); the config-KEY rename refusals had no test, only the removed-FLAG list; a workflow entry's dir: {path, glob, order} and its misplaced-glob refusal had none at all; and the reload's re-read of external documents had none. All four are covered and mutation-checked — reverting the re-read reproduces the original symptom exactly, a "changed":["nothing"] logged after the file on disk said otherwise.

Fixed

  • install.sh no longer depends on one rate-limited endpoint. Resolving "latest" went through the GitHub REST API, which allows 60 unauthenticated requests an hour per IP — a CI runner, a NAT'd office or a shared cloud host can exhaust that without ever having run the installer, and the install then died on a 403 with no way forward but pinning a version by hand. It now falls back to the releases/latest web redirect, which answers the same question and is not API-rate-limited, and the failure message names the releases page.

v1.11.0 — one shape for every document a config names

v1.10.0 made agent.instruction one coherent setting. This release applies the same shape everywhere a document is named, and closes two places where a value was read once at startup and never again.

Changed (breaking, one release old)

  • agent.instruction.http: is now url: (--instruction.http → --instruction.url). A workflow entry's HTTP source has been url: since long before agent.instruction had one, and two spellings for one concept never get fixed later. Both the old flag and the old config key are refused by name, naming the replacement.
  • intelligence.token_file is read at every dial, not once at startup. A Kubernetes projected service-account token is rewritten in place about hourly, and the SPIFFE JWT-SVID configured beside it already re-read per request — a token copied into a startup field was stale from the first rotation onward on a daemon built to run for weeks. A mistyped path is still a startup refusal; a read that fails later logs intel.token_file.error and lets the provider's own 401 speak.
  • A reload re-reads external documents even when the config values around them are unchanged: a workflow entry naming file:/dir:/uri:/url:, and a local skills.dir. Editing a workflow file and sending SIGHUP used to reload successfully and change nothing — the entry it compared was identical.

Fixed

  • The one-shot sugar workflow is decided after the instruction resolves. agentd --instruction X with no workflows synthesizes a main workflow; that decision read the raw config document, where a v1.10.0 long-form instruction: {file: …} is an object rather than a non-blank string. Two consequences, both now closed: the long form got no sugar workflow (a configured agent that did nothing), and a short form naming a document that declares its own :::!workflow got the sugar main on top of its own machinery — a model loop nobody asked for, dialing intelligence and exiting 4. The decision now reads the typed settings, where the source is resolved and the document's directives are already extracted.

Added

  • A folder of documents as one instruction — dir: ./instructions, or dir: {path, glob, order} when the defaults are not right. glob and order live INSIDE dir because they qualify it and nothing else: a setting with nothing to qualify is unsayable rather than validated (writing either beside dir is a refusal that names where they belong). order: name is path order — what a 10-, 20- convention exists to exploit — and order: date is mtime, oldest first; ** recurses. Documents join with one blank line between them; the first file's front matter is kept and a later file's is dropped with a warning; a folder matching nothing is a refusal, not an empty instruction. The short form takes a folder too — --instruction ./instructions/, or any path that turns out to be a directory — and --instruction.glob / --instruction.order compose with it in either order.
  • The same shape for agent.prompt — --prompt.text, --prompt.file, --prompt.dir (with --prompt.glob / --prompt.order setting the folder's own keys), --prompt.url, and the same classification of a bare value, so a path names the file. --prompt-file still works as the earlier spelling of --prompt.file.
  • The same shape for subagents.templates[].instruction — file:, dir: (with its glob/order), url:, oci:, or the text itself. mcp: stays instruction-only: an MCP resource is read and subscribed by a client that does not exist at config load.
  • The same folder source for workflow directories: dir: now also takes {path, glob, order}, through the same code as instructions — config::fileset is the single implementation of "which files, in what order". A flat glob: beside a string dir: is the older spelling and still works; order: exists only in the object form, so the new setting has exactly one home.
  • The watch covers every document a reload re-reads: the instruction file, an instruction folder and its documents, each workflow file: and dir:, and skills.dir. For a folder the watch is on the folder and its glob, because the change an operator makes most often is dropping a new document in — which no watch on the files already there can see. One watcher thread per distinct target.

Deliberately not watched

TLS material (a2a.tls, webhooks.tls, security.*) is restart-only, so a watch there would fire a reload that cannot apply the rotation and would report success anyway — the exact defect class the reloadable/restart-only partition exists to prevent. intelligence.token_file needs no watch at all now.

v1.10.0 — one instruction setting, and it keeps itself current

v1.9.0 taught agentd to fetch an instruction from anywhere. This release makes that one coherent setting: agent.instruction says where the document comes from, how current to keep it, and what to do when its source stops answering.

Changed — --instruction now accepts what --instruction-file did

agent.instruction / --instruction takes the instruction TEXT, a FILE path, or a URI (oci://, mcp://, instruction://, https://) and tells them apart. --instruction-file still works; it is now the explicit spelling of --instruction.file rather than the only way to name a file.

The classification is deliberately narrow, because reading prose as a path would be the worst outcome available: a value is a file only when it cannot plausibly be an instruction — no whitespace at all, and either file://, a path-shaped prefix, or a document extension. A multi-line value is always text. "Summarize the file report.md" stays text; ./agent.md is a file; a named file that is missing is a refusal naming both readings.

Added

  • The long form. agent.instruction also takes an object naming the source outright — text:, file:, oci:, http: or mcp: — plus refresh:, unavailable: and decrypt:. Under it a key means what it says, so only the short form is ever classified. Every setting is optional: the short form is the same setting with all of them at their defaults. mcp: takes the resource URI or {server, resource}, because the URI-only way to name a server nests one URI inside another.
  • --instruction.<key> flags for every one of those: --instruction.oci ghcr.io/acme/agent:v3, --instruction.refresh 60s, and so on. They compose with the short form in either order.
  • http: — an https:// document fetched at load (redirects followed, non-2xx refused, size-capped, envelope-aware). Previously an https:// value was routed to MCP servers as a resource lookup and never fetched.
  • refresh: auto (the default) picks the mechanism that fits the source rather than polling everything: a FILE is watched by inotify — instant, and now watched alongside the config files, which it was not before; a @sha256: OCI pin is never re-read, because a digest cannot change; a mutable :tag or a served resource polls every 5m. off or a duration overrides it, and a change takes effect on reload without a restart. Automatic re-reading previously required an instruction_sources entry — the TRUST section — so an operator who wrote :latest and reasonably expected updates got none, silently.
  • unavailable: decides what happens when the source stops answering after startup: keep (carry on with the last good instruction), freeze (serve live work, refuse new — the §7.7 posture), drain (finish live work, exit 0 so an orchestrator restarts into a fresh read), exit, or auto (the default: freeze a trust-pinned source, keep an unpinned one, because a stale authorization is a security question and an unreachable unsigned artifact is an availability one). Every outcome is one instruction.unavailable log line naming the policy that applied.

At startup an unreachable source stays fatal and no policy softens it: there is nothing to fall back to, and an agent without its instructions is not an agent. For the same reason, an instruction: block that names no source is now refused rather than leaving the agent silently instruction-less — omit instruction entirely for a workflow-only or --prompt agent.

Documentation

configuration.md §5a is the full reference for all of it — classification, what auto does per source kind and why, how refresh differs from instruction_sources[].freshness (a poll interval versus the §7.7 revocation deadline), each unavailability policy, and encrypted instructions.

v1.9.0 — instructions that travel: registries, signatures, envelopes

An instruction document could already define a whole agent. This release is about that document arriving from somewhere else and still being trustworthy: pulled from a registry or an OCI artifact, encrypted end to end, signed by its publisher and verified before a byte of it is interpreted.

The reference parser is now a published crate — agentd-instruction, MIT OR Apache-2.0 — so other implementations of the specification link it instead of reimplementing it.

Changed — action required for one document shape

  • A machinery block of an identified kind now REQUIRES its name attribute. The specification's x-identity (28 kinds) means identity is the name ATTRIBUTE and nothing else; a name: key inside a YAML body is a body field that happens to be called name. agentd previously accepted :::!workflow with the name in the body and delivered a nameless acknowledgement for it. It is now refused at load with workflow requires name. The fix is one line per block:

    :::!workflow          →   :::!workflow{name=drain}
    name: drain               steps: …
    steps: …
    

    Every conforming reader refuses the old shape, so a document that relied on it was already unusable outside agentd.

Added

  • Instruction from an OCI artifact registry (RFC 0040, feature oci, default-off): agent.instruction: "oci://registry/repo:tag[@sha256:…]" pulls the document over the Distribution API v2 at config load — token dance, manifest + blob digest verification (a @sha256: pin must match; a :tag records the digest it resolved to), static docker-config credentials (credential-helper binaries are never executed), CDN redirects followed without the registry token. The document's machinery folds into the config like an inline instruction; the §7.7 freshness watch re-pulls a :tag on its interval. Zero new crates — HTTP + JSON on the existing client, SHA-256 from ring.
  • End-to-end encrypted instructions (RFC 0041, feature decrypt, default-off): any source (inline, file, MCP resource, oci://) may deliver an encrypted envelope — age v1 (X25519 recipients + scrypt passphrases, binary or armored; interop verified against the real age binary both ways) or JWE Compact (ECDH-ES/dir, A256GCM/A128GCM) — and agentd decrypts on the fly with keys from the new operator-only instruction.decrypt section (restart-only; unreachable from a served !config). Decrypting grants nothing: the trust ladder and trifecta apply to the plaintext unchanged. Zero new crates: ring's AEADs/HKDF/HMAC/PBKDF2 plus a hand-rolled RFC 7748 X25519 ladder (ring's agreement API cannot import a static recipient key) and RFC 7914 scrypt core, both pinned to their published test vectors. A build without the feature still detects an envelope and refuses it by name — ciphertext is never delivered as prose.
  • The reference parser as a crate: agentd-instruction (lib instruction_core) — parse, validate, the byte-exact §3.5 delivery pipeline with its §7.4 manifest, the §9.1 block-tree dump, and §7 verification behind a sign feature. Published MIT OR Apache-2.0 from 0.2.0 so the other implementations of the specification can link it rather than reimplement it; agentd itself stays AGPL-3.0-only. agentd consumes it through re-export shims, so config::idoc call sites are unchanged.
  • §7.6 wire verification of registry-served instructions: pinning a publisher in instruction_sources makes verification mandatory — the author JWS against the publisher's key set (pinned key files or JWKS URIs, or discovered from the read's own publisherKeys), key lifecycle enforced (retired verifies past versions, revoked refuses), revoked versions refuse the swap, and the fold runs under grant ∩ ceiling ∩ author-attested capabilities. With reader: set, the audience-bound delivery attestation is verified too. Fail-closed throughout: an unsigned read under a pinned publisher refuses startup, and a failed re-read keeps the running text.
  • Registry-consumer alignment (RFC-0016 §6, RFC-0028 §3.3): an instruction://…@ref read captures the registry's md.instruction/* metadata; the apply boundary is one log line (instruction.applied {old_version_id → new_version_id, delivered_digest}), which is the publish→applied latency measure; one durable consumer binding per instruction is created and reported after each apply; every MCP session announces clientInfo {name, version} plus the workload label; and the current version_id is exposed in status.
  • The §7.7 freshness watch: a durable timer re-fetches a signed instruction on the shortest pinned freshness, and a source unreachable past its deadline REFUSES NEW WORK while live runs drain — a stale authorization stops being usable without stopping the agent mid-flight.

Fixed

  • The machinery acknowledgement named the wrong thing (or nothing) for a workflow whose name lived in its body — the model was told a workflow had loaded without being told which one. See Changed for what replaced it.
  • instruction_spec_corpus and the shared conformance corpus were SKIPPING in CI rather than running: they defaulted to a path outside the repository, so every hosted runner reported ok while executing nothing. The corpus is now vendored in-tree (Apache-2.0, with its LICENSE), absence is a hard failure, and a drift check compares every fixture against the published specification — which CI now checks out so that check runs too.
  • A sleep-step e2e raced its own daemon: a 1s timer could fire and be reaped between the test listing the timer row and killing the process.

Notes for operators

  • oci and decrypt join the release feature set; sign was already in it. A build without a feature refuses the corresponding input by name rather than degrading — an encrypted envelope is never parsed as prose, and an oci:// instruction is never silently skipped.
  • scripts/ci-gate.sh runs what CI runs, including the fifteen-row feature matrix and a cargo publish --dry-run of every crate a release publishes. The publish check exists because a path dependency on an unpublished crate is only reported by cargo at tag time, after the binaries are built.

v1.8.0 — the instruction runtime: forms, delivery, signing

agentd is now a conformant reference runtime for the Instruction Specification (instruction.md, version 1) across §3–§7 — verified against the spec's own sample documents and delivered text by the specification's maintainer. The registry is no longer transcribed into Rust: agentd vendors the spec's JSON Schema and reads kinds, forms, bodies, grants and acknowledgements from it, so they cannot drift from the specification by construction.

Added

  • All §4 forms for every kind that takes them: container (:::!kind), leaf (::!kind{…}), set (:::!kind[] — a table or definition list, one instance per entry) and section (## !kind name), plus the keyword (MUST: …) and alert (> [!NOTE]) prose forms. Fences, leaves and section headings are recognized at column 0 only; a section's region ends at its last non-blank line.
  • The §3.5 delivery pipeline, byte-exact against the spec: prose degrades to its Appendix A form, a set delivers one line ([3 human roles are declared: …]), machinery acknowledges from the schema's templates (kinds with none deliver nothing), when variants are selected against the reader's parameters, include transcludes recursively (own parameters; cycles and unavailable documents degrade to a visible note), inline references degrade to their label, a form delivers its input list, and ${} substitutes last.
  • §7 signing behind a default-off sign feature — no new dependency (the ring already in the tree via rustls): author + delivery JWS/Ed25519 attestations, the resolution manifest (with required variants.dropped), the §7.6 verification order (a signature caps, never grants), and the §7.8 hard floor (compose/identity never over the wire). A sign_roundtrip example produces and verifies a round-trip.
  • instruction_sources operator configuration (§7.5): pinned signed sources — publisher, author/delivery keys, a per-source capability ceiling, and a freshness deadline; pinning is by key and publisher, restart-only. A served :::!config may not write it or document_capabilities (no self-grant).
  • The §7.7 freshness watch: a durable periodic timer re-fetches a signed instruction on its freshness interval and refuses new work while live runs drain when the source is unreachable past its deadline; a successful re-read clears the freeze.
  • Workflow-step reference resolution (to: @human/x, schema: @ui/x, template: @agent/x), @secret-ref/x → {{secret:NAME}} / {{secret-file:PATH}} by the declared kind, and the !mcp deny→exclude mapping.

Changed

  • The single sigiled format is spec version 1 (spec: "instruction/1" in --capabilities); the earlier bare :::type dialect is removed. A bare name shadowing a machinery kind is refused, pointing at the sigiled form.
  • A document's ${parameter} references are resolved by the instruction resolver from their declared source (§5.2/§4.7) and are no longer expanded by the config ${VAR} env layer.

v1.7.0 — the instruction document as an open spec

The instruction-document surface (RFC 0034's ::: directives) is now the reference implementation of an open specification — the Instruction Document Specification, owned by instruction.md, CC-BY-4.0 spec text with an Apache-2.0 conformance corpus. agentd runs that corpus in CI and its RFCs cite the spec as normative: where they differ, the spec governs.

Added

  • RFC 0039 (Draft) — dialect 2 of the instruction document: semantic nesting by fence length, block identity with @refs, 23 block kinds in seven families, and a trust ladder gating each family behind an explicit operator grant (fail-closed, restart-only), because a document that can execute code is a supply-chain surface. Design rationale and implementation record; the published spec is the normative text.

  • The conformance corpus, vendored and run against the real binary (instruction_spec_corpus.rs). It pins dialect-1 behaviour as executable contract — a change that fails a fixture is a spec change, not a refactor — and asserts the spec's per-version registry equals the parser's own closed set (known_kinds()), so the registry cannot drift from the code it describes without CI noticing. A conformance run names the binary it was made against and refuses to report per-fixture results for one that predates directive extraction; the upstream drift check fails on an explicitly configured but missing spec repo, and only skips when unconfigured.

Fixed

  • A dialect-2 document was silently swallowed as prose. open_fence requires an alphanumeric after the colons, so a :::!workflow machinery fence fell through to the prose accumulator and its configuration vanished with no diagnostic — verified against 1.6.0: clean validation, zero registrations. extract() now refuses any :::! fence and any front-matter spec: of major version ≥ 2, naming the dialect-1 rewrite. The refusal keys on evidence of a newer dialect, not on front matter's mere presence, so plain prose files and dialect-1 documents are untouched. This is the forward-compat guard that lets an older agentd fail loudly rather than mis-load a document written for a newer one.

v1.6.0 — a write you cannot read back is refused

Added

  • store.max_value_bytes — refuse a durable write whose serialized state exceeds N bytes, instead of writing it. Unbounded by default, so nothing changes for anyone who does not set it.

    The case it exists for: a store's write limit and its read limit need not be the same number. An MCP-backed store reached through a broker typically caps a tool RESULT well below its request body, so agentd could write a checkpoint it could not read back. The write succeeded; the failure then surfaced at the next boot restore — the moment the agent is least able to do anything about it, because it cannot come back. Refusing at write time keeps the failure where an operator is looking and where store.on_error can act on it.

    Enforced at the single durable-write chokepoint (Durable::put), measured on the serialized state before the CAS loop, so the answer does not drift with a seq that grows by a digit. Over the cap, StoreError::TooLarge names the key, the size and the cap, says the value was NOT written, and points at the two real remedies — compact the context, or raise the cap if the store can read it back. Nothing is stored: a partial write would be the same stranded checkpoint by another route.

    Restart-only, because the value rides the Policy built at startup. A cap an operator believed they had raised, while writes were still being refused at the old one, is exactly the lie the reload partition exists to prevent.

    Reported by the agentctl control-plane session, which hit it managing agents that checkpoint through an MCP state gateway. Their control-plane paths already handle any size via paginated + chunked reads; agentd's own runtime I/O is single-call, which is what left the gap. A chunked-read op was considered and declined: agentd's store profile means "any MCP server exposing four tools is a store", and requiring a non-standard fifth would break that for every other backend. If it is ever wanted it belongs as an OPTIONAL mapping-driven op with the single-call get as fallback.

Fixed

  • A load-sensitive flake in spawn_rate_cap_refuses_after_burst. It minted three children at spawn_rate_burst: 3 / spawn_rate_per_sec: 1000.0 and asserted the fourth was refused — which only holds if three mint_child calls finish inside the 1ms a token takes to refill at that rate. On a machine running the feature matrix they do not, and the test failed claiming the rate cap had not bitten when the cap was working and the clock had simply moved. Split in two: a slow-refill tree proves the cap bites before any refill can occur, a separate fast-refill tree proves a refill re-admits. Verified 0 failures in 8 runs under deliberate 4-way CPU load.

Documented

  • store.retention.runs.{keep_last, ttl} in docs/configuration.md. The mechanism has existed since 1.3.1 and reclaims terminal run records at run-terminal time (nothing in flight is ever dropped), but the default is unbounded — so a long-lived instance keeps one durable record per run for its whole uptime unless an operator sets one of these. It was reachable only from the schema, which is why a control plane looking for it concluded there was no GC at all.

v1.5.0 — events that join, batch, and cross the wire

RFC 0035 phases B and C. Streams already carried events with durable per-consumer offsets; this is the half that lets a workflow wait for a set of them, work them in groups, and let one instance's events become another's.

Added

  • correlate — the multi-event join. depends_on joins steps; this joins events. on lists two or more subject patterns, by names the correlation (a dot path, defaulting to the envelope's own correlation, which emit already sets), and the node fires once when every subject has arrived sharing one value. Half-collected sets live in durable start-state, so a restart resumes a join rather than losing it.

    window is required, and that asymmetry with batch is deliberate: a batch is bounded by size, while a join's pending map is keyed by correlation value and is unbounded, so a pair whose partner never arrives would be a permanent durable leak. max_pending (default 1000) is the second bound and refuses the newest key loudly rather than evicting an older one that might still complete. on_incomplete: fire_partial fires the partial set when the window expires — "paid but never shipped" is the event — and the payload carries complete and missing so a run can tell a partial firing from a finished join. Without those two fields an escalation flow would happily "reconcile" an order that was never shipped.

    Spelled on_incomplete, not the RFC's original on_timeout: that field already means "jump to this step" everywhere else and is validated as a step id, so reusing it would make one field name mean two different things depending on the node kind. The RFC was updated to match the code.

  • batch: {size, window} on a stream consumer — one run per group instead of one per event, for anything that amortises (a bulk write, one LLM call over a page). The payload becomes events[] / count / full, where full: false means the window elapsed before the batch filled. batch and rate are mutually exclusive: both pace consumption and compose confusingly.

  • into: {stream, subject} on webhook and a2a starts — the request or message is APPENDED instead of firing a run. This is what gives a webhook replay-after-downtime: a run fired at request time is lost if nothing was ready for it, while an appended event waits on a durable stream for whatever consumes it, including a consumer that does not exist yet. Verification, filtering and rate limiting still run first, so it is a different destination for an accepted request, not a way past the gate. A refused append answers 503, never a 202 over a dropped event, and respond: sync with into is refused at load — there is no run to wait for.

  • forward: {webhook: URL} / {peer: name} on a stream emit — push the event outward as it appends. The durable copy is the source of truth and the push is only the notification, so a failed forward is logged and does not fail the step; a consumer that missed it still reads the event from its offset. A peer receives a stream.forwarded command, which its own a2a start can bind straight back onto a stream with into: — one instance's emit becoming another's stream event, each side keeping an independent durable copy. Outbound dials go through the same SSRF/egress guard as the http node: without that, appending an event on the way out would be a way to reach a host the egress policy refuses.

Changed

  • A workflow whose every start APPENDS (into:) no longer requires a finish step. Such a workflow is a route declaration, not a graph, and demanding a step that can never execute would both be noise and mislead the next reader into thinking a run happens.

Documented

  • RFC 0035 is now Phases A–C implemented; D (the MCP broker profile) stays draft. Phase C's _feed/_audit sub-item is recorded as satisfied by a deliberate substitution rather than built: the runtime's own events already reach a stream the operator declares, through observability.runtime_events.include (any event family, interface and audit among them) and observability.audit.sink: [stream]. Explicit beats magic — the stream is declared like any other, so its retention and consumers are ordinary configuration, and "a stream that is not declared can never be appended to" stays true.

v1.4.0 — reloads that mean it

Three config paths reported a successful reload and changed nothing. Two were known (v1.3.3 made them refuse rather than lie); the third was found by classifying the whole surface, which is now the thing that stops a fourth.

Fixed

  • a2a.principals reloads. The rules compile into a Resolver the listener held from startup, so a demoted principal, an edited label or a rotated bearer_ref took effect only on restart. The bridge now holds the resolver behind an RwLock and a reload recompiles and swaps it; requests in flight keep the rules they were resolved under. A rebuild that fails keeps the rules already in force rather than falling open on an empty rule set.

  • Webhook routes and their auth reload. Same shape: routes and Verify were built once into the handler. The route table is now rebuilt on every reload — deliberately unconditional, because a route's identity is spread across two sections (its auth can come from webhooks.default_auth while the node lives in workflows[]), and gating on either alone restores the silent no-op. This is what makes a {{secret-file:…}} rotated by a remounted Kubernetes Secret take effect, since that changes no config document at all. Live per-route state — the in-flight counter and the rate-limit bucket — is carried across the rebuild: resetting the counter under running requests would let the parallelism gate admit past its bound, and resetting the bucket would refill an exhausted allowance every time an unrelated key changed.

  • interface.origins reloads — the third instance, found while doing the classification below rather than reported. The browser CORS allowlist was moved into the listener's app state at spawn and never re-read, and it was not restart-only either, so an operator removing an origin to revoke a web client got a successful reload and a listener that kept granting it.

  • interface.debug is applied in both places it lives. It is also an atomic on the observation feed (it is runtime-settable through config.set), and a reload moved only the settings copy, so the two could disagree.

Changed

  • webhooks is no longer restart-only as a whole; webhooks.listen and webhooks.tls are. The socket needs a restart, the rules do not. Likewise interface.enabled and interface.pairing are now named restart-only — arming the observation feed and the pairing flow are startup decisions. interface.enabled refuses even though turning it off would work, because a knob that reloads in one direction only is worse than one that refuses.

Added

  • Every configuration path is classified — RELOADABLE_PATHS joins RESTART_ONLY_PATHS, and every_config_path_is_classified walks the generated schema to prove the two cover all 142 of them. This is the forcing function for the whole defect class: the failure mode is a field that is in neither list, which is not "probably fine" but unexamined, and all three defects above were exactly that. The existing reload e2e could not have caught any of them, because it asserts what the reload REPORTED. Each fix therefore ships with a test that asserts the effect — a retired webhook secret returning 401, a demoted principal being refused, a revoked origin losing its grant — and each was confirmed to fail without its fix.

Documented

  • The RFC index had drifted from the code. Fifteen rows still read "Proposed" for work that ships (0016–0018, 0024–0031, 0033), one was superseded (0015 — --serve-mcp now aliases a2a.listen, since A2A is the only external channel), and RFC 0019 (horizontal scaling) had already been withdrawn in its own file while the index still advertised it as planned. agentd holds no lease and partitions no work by design; docs/scaling.md and the removed-flag messages for --shard/--claim say so. Index and RFC status lines now agree.

v1.3.4 — the socket, the certificate, and the map

Fixed

  • A unix socket was world-accessible for the width of one syscall pair. bind() applies the process umask, so the socket appeared at 0755 and was chmod'ed to 0600 immediately after. Immediately is not atomically: any local process that connected inside that window got a connection the SO_PEERCRED uid gate had already been configured to refuse, and on a shared host that window is reachable. The socket is now bound inside a 0700 staging directory and published with rename(2), so it is never visible at any mode but 0600. The staging directory is removed on both the success and the bind-failure path, and the e2e test asserts nothing is left behind.

  • A2A TLS never picked up a renewed certificate. The listener resolved ServerConfig once at startup and moved it into a TlsAcceptor that lived for the process, so a rotated certificate reached the reload machinery, was accepted, and changed nothing on the wire — a daemon kept serving the old chain until it was restarted, which for a 90-day ACME certificate is a scheduled outage that looks like a mystery. The accept loop now consults a provider closure per connection, so the next handshake after a rotation uses the new chain.

  • --capabilities under-reported the surface it exists to describe. It omitted webhooks entirely (an integrator could not discover the listener, its routes, their methods or their auth without reading the config), omitted the exit-code and config-schema versions, and computed lifecycle.daemon from a hand-maintained list of long-lived start kinds that had fallen out of sync with the registry — the fourth such list in the tree. Long-lived starts are now derived from the node registry (is_long_lived_start), which is the same source start_kinds reports from.

  • The client-auth error recommended a combination that cannot exist. A non-loopback a2a.listen refusing to start said to set a2a.tls.client_ca, a2a.bearer, "and/or" interface.pairing, which reads as three interchangeable options. client_ca builds a verifier with no unauthenticated fallback: it makes mTLS mandatory for every caller, so following the message by adding it alongside a bearer locks out the bearer-only clients and the pairing flow. The message now says so, and docs/a2a.md explains that client_ca gates the transport while bearer and principals decide who an admitted connection is.

Documented

  • Principal rules are first-match-wins, which docs/a2a.md had described as "the most specific rule wins" — true only if you order them that way. The practical trap is now spelled out: where a gateway forwards under one client certificate and distinguishes callers by bearer, the san rule matching that certificate must come last, or it matches everything and the rules below it are unreachable.

  • proto3 JSON field naming, including that GetTask takes plain id (taskId is accepted; task_id is not a field in the protocol) — the shape every integrator so far has had to find by trial.

v1.3.3 — a reload that told the truth

Fixed

  • a2a.principals and webhooks are restart-only. Neither is rebuilt by a reload — principals compile into the Resolver at startup, webhook routes and their auth into the listener — and neither was listed restart-only either, so a change was APPLIED in name only: config.reloaded reported success and the running daemon kept its boot snapshot. Rotating a webhook route's HMAC secret through a reload kept verifying against the OLD secret. A reload touching either is now refused as restart_required naming the path. Refusing is the honest posture until the resolver and listener are rebuilt on reload; a refusal an operator can see beats an apply that lied.

  • The published config schema's Service and A2aPeer $defs had drifted from the loader: Service.kind offered mcp alone against four kinds in the code, and Service.methods, Service.breaker and A2aPeer.service were absent. With additionalProperties: false that made valid configs — including this repo's own examples/voice/hands.yaml — read as invalid in any editor honouring the schema.

    The drift check could not have caught it: CI regenerates the published files from the binary and diffs, which proves the FILE matches the GENERATOR and says nothing about whether the generator matches the LOADER. The struct/schema agreement test walks properties segment by segment, so it could not reach a map value (services.<name>) or an array item (a2a.peers[]) — exactly the two that drifted. It now covers both, and the kind enum, which a field-name comparison cannot see.

v1.3.2 — what an outside integrator found

Fixed

  • The @ completion inserted a bare @name, which the daemon never preloads: a skill is referenced by skills.reference_prefix (default @skill:), and find_references matches that literal prefix. Accepting a completion produced text that looked like a skill reference and loaded nothing. docs/interface.md documented the broken form as working, which is how it survived. The composer now inserts the full @skill:<name>, and the doc says what the daemon does.

    Choosing which side to move mattered: making @ the default prefix instead would have matched the UI, but broken every @skill: reference already written into an instruction and claimed a prefix in ordinary prose. A bare @name is now deliberately free — agentd does not own the semantics of text in a user's message, so a deployment can mean whatever it likes by it.

v1.3.2 — what an outside integrator found

Fixed

  • webhooks HMAC algo: was parsed and ignored. algo: sha512 validated clean and then computed HMAC-SHA256, so every sender signing SHA-512 got a 401 with nothing anywhere explaining why — a silent downgrade in a security field, which is worse than either implementing the digest or refusing the knob. Anything but sha256 is now refused, at --validate-config AND at listener build: a startup-only refusal would have re-opened the very validate/startup divergence the credential scan closes.

  • The ask_human_fallback: wait log said only "no human channel", where the fail branch names the cause ("interface.enabled is off"). wait is the branch operators actually configure, and the parked line is the one place they look; the asymmetry cost an integrator an hour. It now names the key and says the gate will park until its timeout.

v1.3.1 — folders found where the config is

Conventional folders are searched, not assumed

The four folders were looked for beside the LAST config file only. That is right for agentd.yml + agentd.local.yml, and wrong for agentd -c ./agentd.yml -c /tmp/over.yml — an ordinary shape where the overlay lives nowhere near the project. The search found nothing and fell through to the sugar main loop in silence, which is worse than any ordering question.

Candidates are now every config file's directory, most specific first; each setting takes the first that has something for it. Keying on the FIRST would have been wrong too — the chain's first rung is ~/.config/agentd, where nobody keeps a project's workflows.

The working directory is a candidate only when NO config file was loaded. Adding it unconditionally leaked: agentd -c examples/voice/ears.yaml run from a repository root adopted that repository's own ./skills, which is exactly the "a stray file modified a run you spelled out" surprise discovery refuses. Naming a config decides the folders beside it too. Discovery loses nothing — a discovered ./agentd.yml has . as its directory already.

Processing lines as they arrive (examples/tail/)

A worked example for reacting to appended CSV/text lines, with the file's owner as an MCP server — the same edge/daemon split the voice example makes with audio. Tailing correctly is four problems, and the example is honest about all of them: a partial last line is held back until its newline lands, the cursor is a byte offset kept in durable memory (so it survives a restart of either process), rotation and truncation reset it rather than seeking past the end, and advance-after-work keeps the loop at-least-once rather than losing rows on a crash.

v1.3.0 — a project is a directory

The config you did not name

Discovery found exactly one file, .agentd.yml in the working directory. Three rungs now, merged lowest first, with flags and environment still on top:

$XDG_CONFIG_HOME/agentd/config.yml  (else ~/.config/agentd/config.yml)
./agentd.yml
./agentd.local.yml

agentd.yml is the name to teach; the dotted spellings stay valid because they shipped. Discovery remains a fallback — naming a config means the caller decided, and no rung is layered underneath it. Ambiguity is per RUNG: agentd.yml beside agentd.local.yml is the design, agentd.yml beside agentd.yaml is a coin toss and still exit 2. The rule that a discovered file may not RELAX a security control now covers the whole chain rather than its first entry — a machine-local overlay must not widen what the project file could not.

Conventional folders beside the config

workflows/, skills/, subagents/ and context/ fill in the settings nobody wrote, so a project can be a directory you read rather than one long YAML file. Injection happens on the merged document before typing, so validation, {{config.*}} folding, the definition hash and hot reload all see an ordinary explicit entry; the workflows folder becomes the dir: entry the loader already expands, keeping the glob and the sort as one implementation.

Two rules make them conventions rather than declarations: they apply only when the setting is absent (an explicit empty list means none), and only when the folder yields something (a missing folder is silence, where a dir: you named with no match is still exit 2). Load order is filename order.

Skills without a server

skills.dir — and the skills/ folder that sets it — is the first local skill source. Skills previously reached agentd only through an MCP server or an inline :::skill directive, which is the one place the "capability comes from a server" rule bought nothing: a skill grants no tool, it is prose the model reads. Both <name>.md and the Agent Skill form <name>/SKILL.md work, and frontmatter is optional — without it the file stem names the skill and its first paragraph describes it. Like :::skill, a local file wins a name collision with a discovered one.

Fixed

  • {{config.*}} now resolves in a workflow dir: and file: reference, which the comment above the fold always claimed it did. Workflow documents are deliberately excluded from the settings-wide substitution so inline, file, url and dir entries are folded alike at LOAD time — but dir: was consumed by the directory expansion before that fold ran, and validation checked file: existence before it too, so both reached the filesystem as the literal token (workflow dir {{config.wf_dir}}: not a directory).

    It matters because config layers REPLACE lists: var-indirection is the only way an overlay can redirect a workflow folder without restating every entry the base config declared. An undefined var is still named exactly once — the loader reports it, and validation stays quiet rather than adding a second message for one typo.

v1.2.0 — the runtime can address itself

Eight primitives, each closing a gap that turned out to be a missing edge between two mechanisms agentd already had rather than a missing feature. A run could be started ten ways but a turn could only be started from outside the process; the durable log could be consumed but not waited on; the daemon emitted events it could not itself react to. None of them needed a new subsystem — each needed one mechanism pointed at another.

Alongside them: gates that name who must answer, published JSON Schemas so an editor completes a config as you type, and a worked voice-agent example whose point is that a microphone is an unauthenticated prompt channel and the lethal-trifecta gate treats it as one.

Anything inside the daemon can start a turn

message (node) and message.send (tool) deliver into one of this instance's own conversations. Ten trigger kinds could start a run; nothing could start a turn, so the accumulated context — plan, loaded skills, compaction history — was addressable only from outside the process, over HTTP, by someone else. This adds a third producer of an event that already existed, so the delivery inherits durability, crash replay, the per-context lock and pressure shedding unchanged.

The loop guard is hop depth, not volume: a run inherits the depth of the work that caused it and limits.max_message_depth (default 8) refuses a chain before the delivery is durable. agent.on_workflow_finished: think now delivers, so it finally differs from note.

The durable log is a wait condition

wait {on: event} parks a run on a declared stream until an event matching a subject and a CEL match arrives — and match sees the run's own inputs beside the event, which is what makes correlation expressible at all. A timeout makes absence a declared branch. Waiters are grouped by stream so each event is read once per tick however many runs are parked, and waits are anchored at arm time with no from: earliest.

The daemon can react to itself

observability.runtime_events mirrors selected event families onto a declared stream; observability.audit.sink: [stream] gives the audit trail its first supported path off the box. Families are a closed vocabulary, a family may be sampled, the queue is bounded and counts drops, and appends shed under pressure like any other admission.

A verdict on the tool call

security.policies matches a call by name, tag, caller, principal or arguments — the only place an argument can be judged, since grants are name patterns — with verdicts allow / deny / ask / shadow. It covers subagents and turn workers as well as root, because a table that held for one and not the others would be worse than none. shadow refuses and says the call was held rather than fabricating a result; an unanswerable ask denies; an argument guard that will not compile is exit 2. This is also where the trifecta tags finally do work at runtime.

Attribution that travels

identity.autonomous_as names who autonomous work is done as, so a trigger no longer drops the chain at its first hop. a2a.principals[].labels travels into the run, the MCP _meta (agent/acting_for, agent/labels) and the audit line, and principals[].quotas.{rate,budget} are enforced rather than merely parsed. Deliberately not multi-tenancy.

A run's logical identity

key: names the thing a run is about and concurrency.scope: key counts against it — the difference between a queue and a per-entity lock.

The model as a named tier

intelligence.models names each tier once, and model: selects one on every kind that makes a model call — agent and think, plus the five shaping presets (classify, extract, summarize, judge, route). A tier points at a services: entry and may only narrow, inheriting its trifecta tags. Declared windows replace the guess from the model name; unknown tiers and fallback cycles are exit 2.

The presets reach the model by synthesizing a think spec, and that rewrite carried prompt, output_schema and skills but not model — so the field was refused by the parser on exactly the cheap high-volume kinds a tier catalogue exists for, and the omission read as a deliberate limit. The rewrite now carries model too, and a test per preset asserts the tier's wire model reached the provider rather than the instance default.

Workflows as tools

A tool: block registers a workflow as a first-class contract, so a caller gets retry, breaker, idempotency and a human gate inside one apparent call. Startup config only, and tags are derived from what the steps reach rather than declared by the workflow author.

Gates that name a decider

to on the human node and ask_human names who must answer — a principal-id glob, or {id, role, labels} with every condition ANDed so adding one always narrows. A reply from anyone else is refused with an explanation and the gate stays open, rather than the answer sliding into the conversation unnoticed.

The point is that the record is true: "the finance lead approved this refund" is only worth keeping if someone else could not have satisfied it. So an addressed gate is never auto-answered, whatever agent.approval or ask_human_fallback say — a model judge standing in for the named decider makes the record a lie. An operator can still answer, because refusing them would be theatre when they can already rewrite the config or the store; the override is marked operator_override in the task, the log and the audit stream instead. And the audit line now names the person who replied: it previously carried via — "human" or "auto" — which says how a gate was answered and not by whom.

Declarations that name nobody are load errors rather than accepted-and-ignored, since each produces a gate that looks routed and is not: an empty to, a role: anonymous (precisely the identity nothing vouches for), and any typo in a field or role name.

Published schemas, for editor autocomplete

The config and workflow JSON Schemas are served at https://agentd.dev/schema/config.json and .../workflow.json (pinned: config-1.json, workflow-3.json), so a # yaml-language-server: $schema=… modeline or a yaml.schemas entry gives key completion, enum values and typo-flagging as you type rather than at exit 2.

Inline workflows complete too: a workflows: entry accepts a reference or a whole definition, and the config schema now folds the workflow document's own properties in — steps, every node kind and each kind's fields — where it previously left them additionalProperties: true and offered nothing.

The files are generated from the binary and CI regenerates and diffs them, so the published schema cannot drift from the loader. contrib/ carries the SchemaStore catalog entries and the submission notes: once registered there, VS Code, JetBrains and yaml-language-server apply the schema from the filename alone, with no per-project setup.

A voice agent, as an example

examples/voice/ is a household voice assistant in two instances plus a stdlib-only reference MCP server for the microphone and the speaker. It exists because a room full of unauthenticated speakers is the sharpest case the lethal-trifecta gate has: the instance that can hear cannot unlock, the schema-checked command object is the only thing that crosses, and the confirmation gate carries to: {role: operator} so no answer spoken aloud can satisfy it.

It also demonstrates four mechanisms that read as features when combined and are one config line each: on_overflow: replace is barge-in, window: {samples: N} on a subscribe start is conversational context, event: human.asked speaks every gate in the instance aloud (including MCP elicitations, which agentd bridges onto the same machinery), and a schedule calling the mic server's own configure tool makes the wake word durable policy rather than an edge setting. Runs with --fake and no hardware.

Fixed

  • the node registry's think and agent rows had drifted from the binary they claim to be generated from: both accept model: and neither said so.
  • docs/node-registry.md showed on_timeout: on a human gate, which the parser refuses (it is a wait field). A gate that outlives its deadline fails the step, so the example now routes it with on_error and branches on a rendered default — a CEL when over a path a lapsed gate never wrote is fail-closed and takes the whole run with it.
  • the step cache parked its key in the slot every suspending kind overwrites, so it never wrote for agent, think, subagent, human, foreach or wait — the kinds expensive enough to need it;
  • webhook.filter was accepted by config and read by nothing; it now selects deliveries, answering 202 for a drop rather than a 4xx that would earn retries or a disabled endpoint;
  • on_workflow_finished: think was byte-identical to note;
  • Registry::tags_of had no callers, so trifecta tags gated at startup and never at dispatch;
  • a2a.principals[].quotas were never enforced, and never checked for shape;
  • Principal::as_caller was scaffolding for per-caller tool surfaces on one daemon — a design this project declined — and is removed;
  • a human gate's enforcement lived only in the in-memory pending ask: the durable wait record stored an empty detail while a comment claimed "the definition's schema is reapplied by the step". Nothing reapplied it, so a restart rebuilt a gate that accepted any shape — and, once addressees existed, would have accepted any answerer. Both ride the wait record now;
  • the two schemas shared one $id, and it pointed at a URL that 404ed — so any tool caching by identity could serve the wrong document, and no editor could fetch either. They have distinct ids now, matching where they are actually served;
  • the workflow schema omitted priority, unload, file and uri, which the parser accepts. Harmless while the schema was advisory; a red squiggle on a valid document once an editor reads it. A test now holds the schema and the parser to the same field list;
  • the config schema's title still said "(v2)" after the version reset, and config.valid reported "schema":"2" from a hardcoded literal — it reads the version constant now, so the two cannot disagree again;
  • ask_human's contract disagreed with its implementation in both directions at once. to was advertised and read by nothing, so a model could believe it had addressed a question that in fact went to whoever was watching. recommend was the inverse — read by the approval: accept path and advertised nowhere, so under additionalProperties: false a model supplying one was REFUSED, leaving that mode reachable only through a default buried in the caller's schema. Both are advertised and read now.

v1.1.1 — comments that describe the code

Comments and documentation now explain the current logic — the rules, invariants and failure modes — rather than citing the spec documents behind them. Roughly 1,660 internal spec references and a layer of development history ("used to", "no longer", "the old path", plan-phase markers, version-era branding) are gone from code and prose; each was replaced with the reasoning it stood in for. External IETF citations (message signatures, device grant, PKCE, merge patch, resource metadata) are preserved, since they are load-bearing for anyone reading the auth and MCP paths.

The specifications remain in rfcs/ for the formal contract but are no longer published as documentation, so the docs site is current product only.

Reading every comment surfaced places where the documentation and the code disagreed, all corrected: the budget governor documented "tightest window wins" when the code stops at the first refusing scope; a doc comment described parameters its function does not take; the person-server consent flow was called unimplemented while it ships; six doc comments sat on the wrong function; a conversation-turn caller distinction was built and then ignored by both branches; and the README still declared the previous config_version.

Crates: agentd-core / agentd-cli 1.1.1; agentd-mcp / agentd-net 1.0.1. @agentd-dev/cli 1.1.1; ghcr.io/agentd-dev/agentd:1.1.1.

v1.1.0 — the prompt you can write

The system prompt stops being Rust and becomes data plus a template you can override — with loops, conditions and limits over the agent's own environment. The built-in default is written in that same language and printed by agentd --context-template, and it is now ordered for provider prefix caching, which the previous fixed order quietly defeated.

Crates: agentd-core / agentd-cli 1.1.0; agentd-mcp / agentd-net 1.0.0 unchanged. @agentd-dev/cli 1.1.0; ghcr.io/agentd-dev/agentd:1.1.0.

Added

  • The system prompt becomes data plus a template (RFC 0038). The runtime exposes its environment — instance, instruction, workflows, services, streams, subagent templates, skills, peers, parked signals, memory keys, granted internal tools — and a two-block language renders it: {{#if}}, {{#each}} (with this and @index), interpolation and comments. Expressions resolve as a path first, CEL second, so bare lookups work in any build and only real expressions need --features cel (refused at config load otherwise, never mis-rendered at turn time). Two helpers fill CEL's gaps and are available to workflow expressions too: take(list, n) and join(list, sep).
  • agentd --context-template prints the built-in template, which is written in that same language — an override starts as a copy.
  • Named templates, selectable per node. context.template is the instance default; context.templates.<name> are alternates a step picks with context: {template: <name>} — an extraction step can drop the whole environment without inlining one.
  • The default is ordered for prefix caching. Providers cache on the literal prefix, so the shipped template runs stable-to-volatile: persona and instruction, then configuration-derived sections, then live state. Previously parked signals rendered before configuration, so ordinary coordination traffic invalidated the cache for everything after it.
  • context.summarize.prompt / .model — override the compaction summarizer's guidance, and run it on a cheaper model. The summary's JSON schema stays fixed (it is parsed back into the context).

Changed

  • The persona line now names the internal tools this instance actually grants, derived from the registry. It previously recited a hardcoded list, so an instance that narrowed agent.tools.internal still told the model it had subagent.* — briefing it on tools it would be refused.
  • context.cards is removed (shipped in v1.0.0, no users): the template supersedes it, and carrying two selection mechanisms would be worse than the break. A step's context: {cards: […]} becomes context: {template: <name>}.

v1.0.0 — the relicense reset

agentd restarts its public numbering at v1.0.0 under a new license. The whole tree — every crate (agentd-core, agentd-cli, agentd-mcp, agentd-net, agentd-conformance), the Node clients, the site and the docs — is now AGPL-3.0-only, and the git history was rewritten so no commit claims otherwise (the vendored third_party/connectrpc keeps its own Apache-2.0 license and attribution, as it must). agentd remains fully open source; commercial licensing (for proprietary embedding or AGPL-free use) and commercial support are available — contact agent@agentd.dev. The previously published 2.x/0.x artifacts are withdrawn (GitHub releases, tags and GHCR images deleted); the config_version marker becomes "1" and version-branded strings drop the "2.0" era. Everything below this heading ships as v1.0.0; the 2.x entries further down record the same code's pre-reset history.

Crates: agentd-core / agentd-cli / agentd-mcp / agentd-net 1.0.0. @agentd-dev/cli 1.0.0; ghcr.io/agentd-dev/agentd:1.0.0.

Added

  • The service catalog & egress policy (RFC 0037 Phase A). A top-level services: section names the external services a deployment may use — one shared RFC 0031 credential per entry (agentd login service:<name>), authoritative trifecta tags (an unconditional floor for any MCP server whose endpoint matches, so under-tagging cannot launder a sensitive endpoint past the gate), a tool-surface ceiling consumers can only narrow (mcp.servers[].service: references inherit connection settings and may not restate them), and per-instance rate: pacing of workflow mcp.tool steps. security.egress: closed refuses any MCP dial or A2A push target whose URL matches no entry; --validate-config prints each consumer's effective endpoint, admission lists and tags.

  • Subagent templates & instance-tier children (RFC 0036 Phase A). A subagents: section with operator-declared templates: whose instruction is a full RFC 0034 document, section-wide defaults:, and allow_freeform: false to make templates the only spawn path. One resolution rule, two tiers: no machinery ⇒ the existing flat worker (template fields merge under the call site's); machinery (:::workflow/:::mcp/:::stream/:::config/:::tools) ⇒ an instance-tier child — a full agentd daemon composed by the parent (own workflows, signals, streams, file store), supervised and reaped, auto-wired as an A2A peer over a unix socket (output.peer works with a2a.send/a2a.delegate immediately), retired by ttl:/until: (a signal delivered in the child; lifecycle.until_signal) or subagent.retire through the child's own graceful drain, respawned after a parent restart, and capped by limits.subagents.instances.*. The model fills declared, schema-checked params only — extraction runs once at boot on operator text, params fold in as data, and a param value that would introduce a directive fence refuses the spawn.

  • Environment cards in the model's context. The system prompt now carries what the instance can actually reach and park on: ## Services (catalog + closed-egress note), ## Streams, ## Signals (waits parked right now + recently fired), ## Peers (configured + live instance children), and ## Subagent templates (with declared params) — selectable with context.cards: in config and per node via the agent/think step's context: {cards: […], seed: […]} object form.

  • Durability classes — the fast path for recomputable work. A workflow can opt out of persistence entirely with durable: false (runs are memory-only: no run record, no checkpoints, forgotten by a restart — the dominant per-step cost gone), a subagent spawn/template takes the same knob (durable: false = no record, no restore-respawn; a non-durable instance child runs on a memory store), and store.durability.work: ephemeral flips the deployment default with durable: true opting individual workflows back in. The default is unchanged: durable. Mixed shapes get a load-time warning (a durable parent waiting on a non-durable child fails its wait after a restart); the inbox, tasks, memory and credentials stay durable regardless of class.

  • RFC 0037 Phase B — the whole outbound surface. Catalog entries carry a kind: (mcp / intelligence / peer / http; matching is kind-filtered), a2a.peers[].service: references inherit like MCP servers (and give peers agentd login via the shared service:<entry> credential), and closed egress now covers intelligence endpoints, peers, the http step (literals at load, templates at execution, plus per-entry methods: ceilings), the HTTP store and workflow-reference URLs — observability.otel.endpoint is the one named exception. Per-entry rate: pacing now applies in EVERY process (turn workers and flat subagents pace their own in-loop calls), and per-entry breaker: is the default breaker policy for mcp.tool steps (state stays per step). examples/startup/ runs on a shared services.yaml as the reference deployment.

  • RFC 0036 Phase B — instance children grow up. mode: sync with result: {workflow} resolves the spawn with the child workflow's first output (a composed reporter of existing nodes dials the internal _instance.result op home; first completion wins; the child lives on); mirror_streams: forwards child stream events into the parent's same-named streams (source: instance:<handle>); a durable child's token usage meters against the parent's budget windows off its manifest (a non-durable child is invisible to the meter by construction); status shows template/tier/pid/retire_at per child.

Changed

  • The subagent node/tool: template + params instantiate a declared template (instruction and template are mutually exclusive; tools/servers are refused with template — the template defines the grant); the vestigial workflow field — accepted and silently ignored since the v1 in-child driver was removed — is gone from the registry.
  • subagent.send to an instance-tier child delivers over its A2A socket into the child's conversation; subagent.retire begins graceful retirement (SIGKILL only after the drain window).

v2.7.0 — the event-driven company

agentd becomes an event-driven agent, and the docs prove it with a company: durable event streams (RFC 0035 Phase A), an instruction document that can define the whole agent, workflow execution measured 2–12× faster, typed cross-agent commands, and examples/startup/ — eleven instances running every role of a SaaS business except the two founders, written up as The two-person company.

Crates: agentd-core / agentd-cli 2.7.0; agentd-mcp 0.4.0 / agentd-net 0.4.0 unchanged. @agentd-dev/cli 2.7.0; ghcr.io/agentd-dev/agentd:2.7.0.

Added

  • The convenience batch — every gap the examples/startup/ build-out surfaced, closed at the source:
    • wait.on_timeout: <step> — a deadline is an EXPECTED branch: the named step runs (forced), the wait's dependents stay unfired, the run continues. Replaces the on_error: continue + sentinel-switch idiom.
    • Typed A2A commands — a2a.send/a2a.delegate take command + args and send the DataPart the peer's a2a start actually matches on (prose objectives reach the peer's model; commands now reach its registry, deterministically). The a2a start takes schema: — a payload contract enforced at the listener, synchronously, naming the mismatch — and its runs read {{steps.cmd.output.args.*}} typed. A command-fired run now completes its A2A task, so a delegate BLOCKS on the command's actual output.
    • webhook starts take signal: "name/{{ body.field }}" — the webhook→signal relay workflow collapses into one field.
    • memory.push / memory.shift / memory.pop — durable array operations: the queue primitive ({found: false} on empty, so a drain loop just stops), with no model call to mutate a list.
    • stream starts take rate: "<burst>/<per>" — paced consumption turns any stream into a worked-off queue (1/1d = one per day; events wait, durably, in order).
    • human.asked / human.answered internal events — a workflow ({kind: event, on: human.asked}) can escalate an opening gate out-of-band (mail the approver, ring a phone) instead of hoping someone is watching a terminal.
    • switch takes on_no_match: skip — an honest "else: do nothing" (completes, prunes every branch) instead of a forced failure or a dummy default step.
    • Durations accept d (days) and w (weeks) everywhere — 30d, not 720h — including rate windows.
    • intelligence.endpoints: mock:<script> — the built-in mock LLM spawned in-process: a whole agent runs offline with no key and no second process (debug builds always; release under --features internal-mocks).
  • validate counts as a pure data step (no per-step checkpoint), and its schema field is no longer template-expanded.

Fixed

  • A from: new stream consumer could skip events. Its initial offset was resolved on every poll but persisted only when something fired, so everything emitted between arming and the first fire vanished. The anchor now persists the moment it is resolved.

Changed

  • Workflow execution is 2–12× faster (measured; bench/wfperf/). Profiling a 400-step chain showed the cycles going to per-step durable writes and clones, not scheduling. Fixed at the sources: FileStore no longer reads and re-parses the previous envelope on every put (the instance lock makes the in-memory seq exact); workflow definitions are shared (Arc), not deep-cloned per step; pure data steps no longer checkpoint per step — RFC 0025 §7 guards effects, and a crash replays a pure step deterministically, so an inline chain batches into its tick's single checkpoint (a completed foreach batch is still an explicit durability point, and a run-terminal completion still lands immediately); CEL programs are compiled once per expression, not per evaluation; the memory.<key> definition scan is memoized per content hash; and an emit wakes same-process stream consumers in the same reactor iteration instead of the next tick. Wall-clock on the bench shapes: a 200-assign chain 1564→155 ms on the file store (10×), interpolation-heavy 383→32 ms (12×), 300-element foreach 2475→612 ms (4×), CEL-gated chain 670→124 ms (5.4×). Crash-resume semantics verified: SIGKILL mid-chain and mid-batch both restart to the documented durability points.

Added

  • The whole agent from one document (RFC 0034 §5.1). Four config-defining directives — :::config (any v2 fragment), :::mcp{name=…} (an mcp.servers[] entry), :::stream{name=…}, :::tools — let agentd --instruction-file agent.md define everything a config file can: store, lifecycle, limits, MCP servers, streams, tool policy, workflows, skills. The document's fragment merges under explicit config (a file key, env var, or flag beats it; fragment mcp.servers append unless the name exists), through exactly the config file's own deserialization, validation, and reload partition — no parallel pipeline.

  • Per-server MCP tool admission — mcp.servers[].allow / exclude globs gate a server's advertised tool names at the registry; exclude beats allow, and a gated-out tool is absent, not disabled — nothing downstream can resurrect it.

  • Event streams — Phase A of RFC 0035 (agentd as an event-driven agent). Declare durable, named streams under streams: (orders: {retention: {max_events, max_age}}); the emit step gains a stream form (emit: {stream, subject, data, correlation}) that appends a durable event, and the new stream start fires a run per event — including events another workflow published. subject matches exactly or by prefix.* glob, filter is CEL over the event, and from: earliest replays the whole backlog into a consumer that did not exist when the events were emitted. Offsets are durable (a restart resumes exactly where it left off) and consumers dedup by event id — the emit's id is the step's idempotency key, so crash-replayed publishes cannot double-fire. Naming an undeclared stream is a startup error, not a runtime surprise. Spec: RFC 0035 (Phases B–D — correlate, wait {on: event}, edge bindings, the _runtime stream — remain draft).

Fixed

  • An unfiltered event watcher no longer triggers on its own runs. A workflow with {kind: event, on: workflow.finished} and no filter used to fire on the completion of the run it had just fired — forever. Runtime events now never fire the workflow that produced them; watch yourself with an explicit filter if you truly mean to.

v2.6.0 — the instruction becomes a document

Two features that make an agent something you author rather than assemble, plus the missing thirds of two stories v2.5 started.

Crates: agentd-core / agentd-cli 2.6.0; agentd-mcp 0.4.0 / agentd-net 0.4.0 unchanged. @agentd-dev/cli 2.6.0; ghcr.io/agentd-dev/agentd:2.6.0.

Added

  • Instruction directives (RFC 0034, docs). agent.instruction is now a specified format — prose that may embed machinery in the :::type{attrs} colon-fence syntax: :::workflow bodies join workflows: exactly as inline entries (same folding, validation, hashing, pinning, retirement — and no sugar main loop when an instruction declares its own machinery); :::skill{name} defines an inline skill with no MCP server, referenced as @skill:<name>; :::context / :::example mark text as reference or few-shot, wrapped in tags the model reads unambiguously. The model always sees the cleaned text. Executed ONLY from operator-authored surfaces — never conversation text — fail-closed on unknown names, unclosed fences, bad bodies.
  • Graceful workflow retirement. Every way a definition leaves — reload removal, replacement by a new version, workflow.delete, an instruction edit — now takes one path: disarm + unsubscribe MCP resources nothing else wants, pin the definition for live runs, stop admitting, apply the workflow's own unload: {policy: drain|cancel|detach, timeout} (default drain), and garbage-collect the pin when the last run lands (workflow.retiring / workflow.unloaded).
  • Circuit breakers on remote-effect steps — breaker: {failures, cooldown} on http / mcp.tool / a2a.send / a2a.delegate: N consecutive failures open the circuit, further attempts fail instantly without dialling (breaker open error prefix — route fallbacks with on_error + switch), one probe per cooldown closes or re-opens it. State is durable and shared by fan-out iterations; proven by an e2e spanning five daemon processes.
  • Outbound rate pacing — rate: "<burst>/<per>s" on the same kinds: past the burst the step waits on a durable timer (consuming neither an attempt nor a retry) instead of failing, so fan-outs drain at the declared pace. With retry (exponential, jittered — already shipped) the trio covers the failure taxonomy: transient / outage / quota.
  • Docs: Directives and PID 1 — agentd as init concept pages; RFC 0034; RFC 0033 restored to the site index; the landing install panel grew curl/docker/npm/AI-agent channels with a copy button.

Fixed

  • workflow.delete stranded its own live runs (definition lost mid-flight); a reload leaked pinned definitions and MCP subscriptions forever; a failed reload left the workflow registry empty. All three are what the retirement path now guarantees against.
  • A start node whose inputs: mapping fails to render now refuses to fire, loudly (start.inputs.invalid) — it used to fire with silently-empty inputs. The event start's payload shape ({event, payload: {run, workflow, status}}) is now documented.

v2.5.1 — the answer must beat the obituary

A patch on the day of 2.5.0, for a defect the release's own CI surfaced. The 2.5.0 wake fix routed child frames through a forwarder thread; on a loaded machine the forwarder could be descheduled, a child's exit could overtake its final result frame, and a worker that had completed was failed as "worker exited without a result". Readers now send directly into the reactor's queue (a FrameSink closure — no hop thread), so joining a child's reader is a real ordering guarantee: every frame it wrote is queued before its reap is processed. Verified where it failed: the implicated suites pinned to 2 CPUs against busy-loop spinners, 130/130 green.

Also: schedule_at_once_e2e now waits for outcomes instead of the runner's clock (the other thing a slow CI machine disproved).

Crates: agentd-core / agentd-cli 2.5.1; agentd-mcp 0.4.0 / agentd-net 0.4.0 unchanged. @agentd-dev/cli 2.5.1; ghcr.io/agentd-dev/agentd:2.5.1.

v2.5.0 — pressure, priority, and the fast lane

An agent that accepts work it cannot finish is worse than one that says no. This release teaches the daemon to feel its own limits — disk, memory, CPU — and to degrade in the order the operator chose; it makes retries safe to send twice; and it makes the runtime fast where measurement, not intuition, said it was slow.

Crates: agentd-core / agentd-cli 2.5.0, agentd-mcp 0.4.0 (RawResponse gained response headers); agentd-net 0.4.0 unchanged. Display clients ship as @agentd-dev/cli 2.5.0; the image is ghcr.io/agentd-dev/agentd:2.5.0.

Performance — measured, then fixed

  • Data pipelines run at execution speed. The scheduler now re-runs to a fixpoint while inline steps (assign, map, template, switch…) keep completing, instead of advancing one step per 200 ms tick. A 200-step chain: 42.4 s → 2.26 s (debug build).
  • A child's answer wakes the loop. Subagent and turn-worker frames ride the same channel the reactor parks on, so a 5 ms answer no longer waits out the tick. A cross-agent delegation round trip: 214 ms → 18 ms. Reaps are re-queued once behind the child's already-flushed frames, so "exited without a result" cannot be a race.
  • subscribe reads moved off the loop. The notify-then-read for subscribe start nodes ran on the reactor thread; a slow MCP server could stall the daemon per update. Same fix the wait on: resource path already had.
  • A2A streaming results arrive. The server sent the terminal status frame before the result artifact; a conformant client stops at terminal, so the answer was dropped ("completed without a distillate"). Artifact now precedes the final status, and the client recovers over unary GetTask when a peer still orders it the old way.

Added

  • Resource pressure (store.file.min_free, default 256 MB): the daemon watches the store filesystem's headroom (and the cgroup's memory), warns at 2×, and below the threshold sheds new work while in-flight work drains — schedules skip with start.shed, webhooks answer 429 + Retry-After (after authentication), queued turns stay queued, workflow.run and subagent spawns refuse with the cause. Transitions log once (pressure.warn/shed/cleared); metrics schema 1.2 adds agent_pressure_level, agent_disk_free_bytes, agent_runs_active, agent_turns_queued.
  • Priority — priority: low|normal|high on workflows and subagent spawns. low sheds one pressure level early (at warn), higher-priority runs schedule first each tick, and priority maps to OS niceness (low → +10, high → −5 best-effort). A tiebreak under scarcity, not a reservation.
  • OS resource caps for subagents. limits: {memory: 512MB, cpu: 5m} become RLIMIT_AS / RLIMIT_CPU between fork and exec — kernel-enforced, beside the existing steps/tokens/deadline budgets. Verified in tests against /proc/<pid>/limits of the live child.
  • Idempotency for remote effects. http (idempotency: {header|query, value?}), mcp.tool (automatic agent/idempotency_key in _meta), a2a.send/a2a.delegate (idempotency: true pins the A2A messageId). The default key is derived — sha256(run_id.step_id), stable across retries by arithmetic, opaque on the wire — and value: substitutes an application key. The old mcp.tool key named the attempt, which defeated the field's purpose; the attempt now rides separately. Steps also see env.step, env.attempt, env.idempotency_key.
  • Unix-socket A2A for co-located instances. a2a.listen: unix:///path and peer endpoint: unix:///path: same protocol, no TCP or TLS — the socket file is 0600 and every connection's SO_PEERCRED uid must be the daemon's own user (or root), which is strictly stronger than loopback TCP. TLS material on a unix listener is refused; webhooks deliberately stay https://.
  • Config vars — named values (any JSON type, nested) referenced as {{config.NAME}} anywhere a string sits, substituted at load time so the definition hash pins the resolved workflow; exact-token references keep the value's type. The startup preflight now reports every unresolved {{secret:…}} / {{secret-file:…}} / {{config.…}} reference across the config and all loaded workflows in one refusal.
  • --prompt-missing — each missing {{secret:NAME}} is asked for on /dev/tty, echo off, one by one; values live in process memory only and resolve exactly like environment ones. Refused without a controlling terminal. Tested against a real pty, including that the typed value is not echoed and reaches the point of use.
  • --env <FILE> (repeatable) — a dependency-free dotenv subset loaded before anything reads the environment; the real environment beats any file, later files beat earlier. A malformed line refuses startup naming file:line.
  • Workflow sources. A workflows: entry can be fetched by url: (with headers, timeout, and the same SSRF guard as http nodes; fail-closed at startup) or discovered by dir: + glob: (** crosses directories; zero matches is a refusal). security.workflows.immutable: true makes the loaded set read-only for the agent itself — workflow.create/update/delete are refused and logged workflow.locked.
  • subscribe window — window: {samples: N} (≤ 256) keeps a durable ring of the last N read values and delivers it as output.window, oldest→newest: the trend, not just the reading, for hardware-driver-style streams. The ring accrues through a debounce — coalescing drops firings, the window keeps the samples.
  • Webhook arrival throttling — rate: "<burst>/<per>s" per route: past the burst the route answers 429 with a computed Retry-After, before anything is written to the durable inbox. parallelism bounds how many run at once; rate bounds how fast they arrive.

Changed

  • agentd-mcp's RawResponse carries extra response headers (how the webhook listener says Retry-After) — the 0.4.0 bump.
  • The unix: scheme, retired for MCP/intelligence endpoints in the 2.0 pivot, returns only for A2A listeners and peers; vsock: stays retired.
  • Docs: configuration.md (vars, sources, --env/--fresh/--prompt-missing, min_free), operations.md §7 (the shed/drain story), workflows.md and the node registry (idempotency, rate, window, subagent caps, priority), a2a.md (the unix-socket lane), architecture.md (the loop, as it now is — with the measured numbers).

v2.4.0 — the display clients grow up

The clients could show you that an agent was working. This release is about showing you what it is doing, letting you act on it, and letting the UI be hosted somewhere other than the machine the daemon runs on.

Crates: agentd-core / agentd-cli / agentd-conformance 2.4.0; agentd-net 0.4.0 and agentd-mcp 0.3.1 unchanged. Display clients ship as @agentd-dev/cli 2.4.0; images are ghcr.io/agentd-dev/agentd:2.4.0 and, new, ghcr.io/agentd-dev/agentd-ui.

Added

  • A hosted web UI. The client is a thin one — the page connects to your daemon from your browser, and the host never sees a request to an agent or holds a credential. That is what makes it safe to be public, and CI asserts the bundle stays that way. Ships as an OCI image (unprivileged nginx, amd64+arm64) and a static tarball; docs/hosting-the-ui.md covers the deploy.
  • Private Network Access. A page on a public origin reaching a daemon on loopback is the shape browsers gate: Chrome drops the request unless the preflight is answered with Access-Control-Allow-Private-Network: true. agentd now answers it, riding the existing interface.origins allow-list so it grants the origin you configured and no other.
  • Status values a workflow maintains. A memory:<key> display item renders whatever a workflow wrote to that key — a branch, a PR number, a deploy state. agentd still executes nothing locally; the workflow reads the value from an MCP server and the chrome shows it. Unset renders nothing, and TTL is honoured so a stale value empties rather than lying.
  • An approval policy. agent.approval: ask (a person answers), auto (an LLM judge decides, marked as such), accept (take the ask's recommendation). Runtime-settable, because how much you want to be asked changes with what the agent is doing. accept degrades to auto when there is no recommendation rather than inventing an answer.
  • Gates that ask the way their schema says. The answer schema now travels with the gate, so a client offers the actual choices — single-select, multi-select, "other", yes/no — instead of a text box the person guesses the wording for. Free text still works for anything a form cannot express.
  • Subagent control. Message a warm subagent or stop one, from the client that is already showing it. The web UI keeps the list beside the detail; the TUI uses m and k, with a confirmation on the one that is not undoable.
  • Per-step visibility. Every step transition reaches the clients, so a run shows what it is doing rather than "3 done, 1 running" — with per-step durations, because the slow step is usually what you came to find.
  • Step breakpoints. workflow.pause {before_step} stops a run just before a named step, so it can be inspected in the state it is in rather than one effect later. Durable, like pause itself.

Changed

  • Durations everywhere they mean something: the live counter while working, what it settled at when the turn ended, and how long each step took. A step whose start was never observed reports no duration rather than a wrong one.
  • Colour carries meaning rather than decoration, and body text keeps the readable neutral even when its mark is coloured.
  • The interface documentation shows the program, illustrated with frames the shipped TUI actually rendered and regenerated by npm run frames, so it cannot drift into describing something agentd no longer does.

v2.3.0 — CEL in the box, and the branch nobody chose

A feature-parity analysis against LangGraph and LangChain raised 59 candidate gaps; 56 survived adversarial refutation. This release is the roadmap that came out of it, and the headline is a correctness bug: the branch a switch did not take still ran its whole tail.

Crates: agentd-core / agentd-cli / agentd-conformance 2.3.0; agentd-net 0.4.0 and agentd-mcp 0.3.1 are unchanged. The display clients ship as @agentd-dev/cli 2.3.0; the image is ghcr.io/agentd-dev/agentd:2.3.0.

Fixed

  • An untaken branch ran its downstream. switch marked the not-taken case targets Skipped but never their descendants, and a skipped step satisfies its dependents — so everything below the dead branch ran, sometimes before the chosen branch's own steps. The satisfying behaviour could not simply be removed: it is what lets a workflow with several start nodes fire one and still run the graph below the others, and what lets an uneven join proceed. So Pruned is now a distinct status — terminal, and not satisfying — with the rule that a step is pruned only when every inbound path is pruned. One live parent keeps it alive. A false when guard prunes for the same reason. Sibling start nodes stay Skipped and keep satisfying.
  • Fan-out was sequential by default and silently clamped at 8. foreach defaulted to one lane at a time — a loop with extra syntax — and a request for more than the ceiling was quietly reduced, so a workflow could run eight-wide while its author believed it ran fifty. The ceiling is now limits.workflow.fan_out, over-asking is refused at load naming the step, and the default is 4.
  • A stall was reported as "no ready step", which is a symptom. It now names the first failed ancestor, and a run blocked behind a failure ends failed rather than stalled.
  • Retry had no jitter, so steps that failed in one wave retried in lockstep. Deterministic per (run, step, attempt), so a replay reproduces the schedule.
  • A workflow silent about limits ran unbounded. It now inherits limits.run.*, which the documentation already described as applying.

Added

  • CEL ships in the released binaries. when, until, filter and the expr of map/filter/reduce are how a workflow branches, and they were refused at load on every published build — so any non-trivial workflow was a build-from-source job. It costs 1.86 MiB (6.62 → 8.48 MiB amd64) and 23 crates. exec stays out; that one is a security posture, not a size decision.
  • The A2A workflow nodes. a2a (start), a2a.send and a2a.wait were refused by the parser; all three now work, which makes the asynchronous half of an agent-to-agent conversation expressible. A workflow declaring {kind: a2a, command: "x"} REGISTERS x as a command the listener accepts. wait {on: message} is fixed by the same change — it could previously only ever end by timing out.
  • Declared state. state: {key: {schema, reducer}} — a schema gates the write at the step that produced it, and a declared reducer turns the concurrent-write check from a heuristic into a policy.
  • Retention. store.retention.runs: {keep_last, ttl} evicts terminal runs; before this a long-lived instance kept one record per run forever.
  • Step-level debugging. Every step transition emits a step feed event, and workflow.pause {before_step} is a durable breakpoint.
  • A node registry at docs/node-registry.md — all 67 kinds with their required fields, generated from the binary's own registry.

Changed

  • Declared knobs now work or are refused. outputs.schema was checked for well-formedness and never applied; on_replay was published and read by nothing; checkpoint was an alias for noop; human.to/reply_uri were accepted and ignored; collect.mode typos fell through to overwrite; store.durability: eventual promised weaker-but-faster writes that were never wired. Each now does what it says, or fails saying it does not exist.
  • Two concurrent steps overwriting one variable is refused at load — a last-write-wins race decided by completion order. Reducers, ordered pairs and mutually exclusive switch arms are exempt.
  • A human gate inside foreach/parallel/batch/race is refused at load until each gate carries its own identity; only one can be live per run, so the second item would wait for a reply that can never reach it.
  • A human's answer is checked against human.schema, and a mismatch re-asks rather than failing the step.

v2.2.0 — durability by default, and the defects a real review found

The headline feature is small and the bug list is not. A multi-agent review of the 2.1.0 tree raised 34 findings; 33 survived adversarial refutation, and the worst of them wedged the daemon on default configuration. This release is that list, closed, plus the store that should have existed all along.

Crates: agentd-core / agentd-cli / agentd-conformance 2.2.0, agentd-net 0.4.0 (new public API), agentd-mcp 0.3.1. The display clients ship as @agentd-dev/cli 2.2.0; the image is ghcr.io/agentd-dev/agentd:2.2.0.

Added

  • A local file store (RFC 0033), and durability by default. A long-lived instance — a schedule, a subscription, an A2A listener, a goal — used to exit 2 unless you had already stood up an MCP or HTTP coordination backend. Durability should be something a laptop already satisfies. store.kind: file is a full adapter: seq CAS, atomic writes (tmp → fsync → rename → fsync dir), 0700 directories and 0600 files at every level, path traversal closed at the adapter, and an exclusive flock so two instances on one directory fail at startup naming the holder's pid rather than interleaving into corruption.

    Identity is agent.name, not a hash of the config. A hash looks automatic and is actively harmful: adding an MCP server or fixing a typo changes it, so the agent starts fresh, abandons its in-flight workflows and orphans the old state — silently, from the most ordinary edit anyone makes. The digest is kept where it belongs, in the manifest, logging store.config_changed and resuming anyway. --fresh exposes the generation counter the manifest already had.

    One-shot runs are unchanged (they still write nothing), and an explicit store.kind: none is still refused. Durability is the filesystem's property, not agentd's: on a container's writable layer this survives a restart but not a reschedule, and the defaulted store says exactly that at startup.

Fixed (BREAKING for two configurations — see the last two entries)

Ship-blockers.

  • The reactor livelocked on its own default concurrency policy. A start event over the cap was pushed back onto the deque process_inbox was draining, and the cap can only be relieved by a later step of the same tick. The single writer spun at 100% CPU forever: no timers, no checkpoints, no SIGTERM. on_overflow: queue is the default and max_runs defaults to 4, so a workflow with no concurrency: block at all wedged the daemon on its fifth concurrent start. The default path had never been executed past the cap.
  • One malformed request killed the daemon. A SendMessage whose params was not an object panicked the A2A listener through serde_json's IndexMut, and the release profile is panic = "abort".
  • A hostile chunk header aborted the process — the accumulated-length check overflowed. Reachable at startup, since agentd dials every configured MCP server then.
  • poll_pending removed by a stale index, panicking the reactor when a reply re-entered and pruned the table underneath it.
  • A turn ending mid-tool-call wedged its context permanently: an assistant message with tool_calls no result ever answered, replayed from durable state on every later turn and every restart.
  • A dead turn worker never failed its step. The guard asked whether the child was still in the table; the reap path removes it first.

Security.

  • The SSRF guard was decorative. It resolved a name, classified the addresses, then discarded them — and the callers dialled by name, resolving a second time. Hostile DNS answered the check with a public address and the connect with 169.254.169.254. The guarded URLs are exactly the model- and peer-supplied ones. Now resolve-once, dial the vetted address, re-assert on every entry at the syscall boundary; TLS/SNI and Host stay on the hostname.
  • Two more credential-bearing dials took their URL from the remote side — the AAuth Person-Server consent poll (a signed, token-bearing GET repeated twice a second for five minutes at a PS-chosen address) and RFC 9728 discovery (whose answer then chose the issuer for the authenticated flow).
  • SubscribeToTask attached to any task id with no ownership check.
  • A {{secret:…}} that failed to resolve was silently dropped, so the daemon started and dialled the model with the authorization header absent.
  • subagent.run's tools narrowing was accepted and never enforced — the grant was written into the spawn payload and read by nothing, so a parent bounding an untrusted sub-task got a child with the full catalogue.
  • A discovered .agentd.yml could lift the lethal-trifecta gate — cd into a repo you cloned, run a flags-only agentd, and that repo's dotfile governed your grant. Discovery is a convenience and may no longer relax a security control; an explicitly named --config still can. (Behaviour change for anyone relying on 2.1.0 discovery to configure security settings.)
  • A non-loopback webhooks.listen with no auth was a warning, while a2a.listen in the same situation was a hard error. Both are inbound listeners that trigger work. It is an error now, checked per route. (Breaking: a public webhook listener with an unauthenticated route now exits 2. Set webhooks.default_auth, or give the node its own auth.)

Correctness and robustness.

  • cancel_scoped_children matched nothing: element and branch children are keyed parent[ix].step / parent{branch}.step, which never start with parent., so the foreach/parallel failure paths and the race timeout cancelled no child, disarmed no timer and dropped no pending entry.
  • timeout on race, join and workflow.wait was silently dead — the parser stripped it before the handlers read it.
  • A schedule with at: re-armed forever: a job asked to run once at 03:00 ran continuously from 03:00 onward.
  • Timers::fire deleted the durable timer before checkpointing its effect, orphaning the suspended step on a crash in that window. Restore gained a repair pass for a Suspended step whose timer did not come back.
  • The reactor did a synchronous MCP resources/read on the single-writer thread, stalling timers, checkpoints, drain and SIGTERM for up to the MCP timeout — on the subscription path, which is agentd's whole reactivity story.
  • Compaction could leave an assistant message first, which the Anthropic dialect cannot send; the context is durable, so one bad fold poisoned every later turn.
  • MCP elicitation could not work: the answer was a bare string (so every request became cancel), the notification stream dialled without the request signer, and an interleaved server→client request was buffered until timeout.
  • The A2A task id counter reset to 0 on restart while tasks were restored, so a new message silently joined a restored task.
  • The A2A config command returned the raw merged settings, credentials in the clear, over a remote protocol surface.
  • Non-ASCII in a JSON/JSONC config was silently mojibake'd by a byte-wise comment stripper.
  • The observation feed never signalled resync when a client's cursor was ahead of it, so display clients silently stopped receiving events after a restart.
  • The HTTP request reader bounded neither header bytes nor header count.
  • principals::bare() leaked a String per request on an attacker-controlled method name.
  • intelligence.budget.windows[].reset panicked instead of exiting 2 on a multi-byte character.

Changed

  • The measured footprint is re-measured rather than inherited. Idle RSS is 5.5 MiB on one thread with a schedule workflow and a file store, and one CPU jiffy per six seconds. The README claimed ~2 MiB and why-rust.md 3.8–3.9 MiB; both predated the protocol SDKs and neither had been re-measured. The README's footprint table also drops its retired three-dependency row and its stale v1.0.0 attribution.

v2.1.0 — a project config, and a build that needs no C toolchain

Added

  • .agentd.yml is discovered. An invocation that names no config — no --config, no AGENT_CONFIG — now loads .agentd.yml (or .agentd.yaml) from the working directory, the way a linter or a formatter finds its dotfile, so a project with a checked-in config stops repeating the flag. It is only ever a fallback: naming a config means you have decided, and the dotfile is not consulted, merged, or layered underneath it. Only the working directory — no walk to a parent, no $HOME, no /etc. Both spellings present is an error (exit 2) rather than a silent pick between them. --help, --version, --config-schema and --workflow-schema never discover a config, so a malformed dotfile cannot stop you reading the help. (docs/configuration.md §12.1.)

Fixed

  • The build no longer needs a C toolchain. connectrpc, a hard dependency of a2a-rs, declared rustls, tokio-rustls and hyper-rustls with their default features, which selects the C/assembly aws-lc-rs provider — and because Cargo unifies features additively and globally, that one default applied to the whole graph however carefully agentd, agentd-net and a2a-rs itself asked for ring. It put cmake, make, perl and a C++ compiler in the builder image, and it hung the v2.0.0 release's cross-compiled amd64 job for 90 minutes while the emulated arm64 job finished in three. A vendored connectrpc with three corrected dependency entries and no Rust source changes removes it (third_party/connectrpc/PATCH.md); aws-lc-rs, aws-lc-sys and rustls-native-certs leave the graph. Cross.toml existed only to install cmake and is gone, the Dockerfile is back to musl-dev alone, and cross-building arm64 now takes 2 minutes. CI asserts aws-lc stays out, from the job that installs no cmake.

    This does not reach cargo install agentd-cli or crates.io consumers of agentd-core: [patch.crates-io] is workspace-local, and a published crate cannot turn off a transitive dependency's features. Those builds still need cmake until the fix is upstream. Every artifact we ship is unaffected.

  • The v2.0.0 amd64 release asset was stale. The SHA256SUMS and the x86_64 tarball on the v2.0.0 release dated from an earlier build of that tag and did not contain the 2.0 runtime, so an amd64 user installing v2.0.0 received the older binary while --version reported 2.0.0. Both are rebuilt from the tag and replaced, verified by behaviour rather than by --version. arm64 was always correct.

  • The documented binary size was wrong — 2.98 MiB, measured on that stale asset. The shipped binary is 6.57 MiB on amd64 and 4.98 MiB on arm64 (3.00 / 2.79 MiB compressed).

v2.0.0 — the durable agent: A2A, workflows, display clients, and protocols from their own SDKs

The rewrite lands. agentd 2.0 is a daemon you can attach to, hand work to, and hold to account: durable workflows that survive a restart, an A2A surface other agents and your own display clients speak, human-in-the-loop gates that reach every attached screen, and — as of this release — MCP and A2A implemented by rmcp and a2a-rs rather than by us.

Breaking. The 1.x execution modes and their flags are gone (each exits 2 naming its replacement), MCP and A2A wire details moved to strict proto3 JSON, and clustering was removed rather than finished. Read the three BREAKING sections below before upgrading.

Crates step to agentd-core / agentd-cli 2.0.0 (agentd-mcp / agentd-net 0.3.0); the display clients ship as @agentd-dev/cli 2.0.0; the image is ghcr.io/agentd-dev/agentd:2.0.0. The MSRV is 1.96, set by the protocol SDKs.

Removed (BREAKING: clustering and sharding are gone)

Shard identity, work-claim leases and the standby pool were declared in the config schema, validated at startup, and read by nothing. They had been that way for a long time; the honest options were to finish them or remove them, and the premise turned out to be wrong for agentd, so they are removed.

Coordination needs a shared source of truth. agentd already talks to two that are better placed to own it than a replica is — the MCP server the work comes from, and the store. A queue can hand an item to somebody else when a lease expires; no agentd-side hash can. So a fleet partitions upstream: one subscription per replica, or the queue's own claim/lease semantics called from a workflow step. Both are described with working config in docs/scaling.md.

Gone: the cluster build feature; cluster.shard and cluster.timer_shard; --shard, --claim, --claim-ttl, --claim-renew-fraction, --standby, --assign-from and their env aliases; claim and shard as fields of a subscribe start node; and the agent_shard_skipped_total, agent_claims_* and agent_saturation metrics, which were reserved names flat at zero. Each removed flag now exits 2 naming what to do instead. RFC 0019 is marked Withdrawn, with the reasoning kept.

One thing this gives up honestly: in a fleet, three replicas arming the same nightly schedule will run it three times. That is a real problem, and the right place to solve it is one line of deployment config — replica 0 arms the schedule, the others do not — rather than a hash inside the agent.

Added (push notifications and the authenticated agent card)

  • Push notifications. A caller registers a webhook and agentd POSTs its task's updates there instead of the caller holding a stream open. Default-OFF at two levels, because the URL comes from a peer: a2a.push.enabled says you will make the request at all, and a2a.push.allow_private — a separate and larger decision — says you will make it to a private or loopback address. The target is guarded at registration and again at delivery, since a name can resolve somewhere new in between. Configs are durable with the task, so a restart keeps the promise the caller was given.
  • GetExtendedAgentCard. The authenticated card: the same document, with the skills this caller may actually run rather than every workflow. An anonymous caller gets -32007.

Fixed

  • The agent card named no interface. It carried the older flat url/preferredTransport, and nothing in supportedInterfaces — so a peer reading the card parsed it happily and had nowhere to send anything. Discovery is the card's whole purpose; the interface is now there, and the spec oracle asserts it.
  • SendStreamingMessage sometimes did not stream. A command DataPart was answered with a JSON body, so a caller that asked for SSE could not parse the reply. A streaming method answers with a stream whatever the message contained.
  • shard on a subscribe start is refused rather than ignored. It promised partitioned deliveries and filtered nothing, so a fleet built on it processed everything N times while looking configured. Failing to start is the honest answer; docs/scaling.md says what to use instead.

The spec oracle grew from 8 checks to 13 — discovery, capability honesty, cancellation, stream resumability and the push-config surface — which is how the first two fixes above were found.

Changed (BREAKING: MCP and A2A are the published implementations now)

agentd used to implement both protocols itself. It does not any more, and the reason is worth stating: a protocol written from your own reading of a specification fails silently, in the peer — your tests encode the same reading as your code, so they agree with it, and what finally disagrees is somebody else's client, reporting nothing. Checking agentd's A2A output against an independent implementation found four such faults in an hour (see the entry below); that was the argument.

  • MCP is rmcp, the official Rust SDK: the handshake, the typed requests and notifications, capability negotiation, the streaming rules, the error mapping, the version table. It is not a feature you can turn off — there is no second implementation to fall back to; the hand-rolled client, the era probe and the modern-dialect implementation are gone, and so is the rmcp-client flag.
  • A2A is a2a-rs, generated from the specification's protocol buffers: method dispatch, the typed shapes, SSE framing, the blocking-send rule, the error codes. agentd supplies the ports — identity, the authorization matrix, and the durable tasks the reactor owns.
  • The socket stays agentd's. Both SDKs run over agentd's own HTTP transport, so nothing that was working stopped: AAuth request signing with its challenge/re-sign loop, AWS SigV4, mTLS client identities, OAuth refresh, the SSRF guard. There is no split fleet and no fallback path.

Two bugs surfaced during the migration, both of the quiet kind: the SDK's notification hooks were not connected to agentd's wake path (a reactive daemon that never wakes), and the SDK ran on a current-thread runtime that only advances inside block_on (so dispatch happened only while agentd was already busy). Both fixed.

What this costs. The dependency graph went from 26 crates to 182 in the shipped feature set, and the MSRV moves to 1.96, which a2a-rs sets. The build stays pure Rust: connectrpc (under a2a-rs) asks for rustls with default features, which would have selected the C/assembly aws-lc-rs provider for the entire graph, so a vendored copy with three corrected dependency entries keeps it on ring — see third_party/connectrpc/PATCH.md. Building this repository needs no cmake and no C++ compiler; building agentd-core from crates.io still does, because a published crate cannot turn off a transitive dependency's features. The CI job that asserted three direct dependencies is retired, replaced by one that guards what a user actually receives: the release binary is still a statically linked musl artifact on scratch — about 6.5 MiB, no shell, no libc, nothing to scan but agentd itself — and asserts aws-lc has not come back. The docs that claimed the old posture (README, architecture.md, why-rust.md, mcp.md, the landing page) say this instead.

What this changes on the wire. The modern/stateless MCP revision now follows rmcp's schedule — it pins LATEST to the legacy revision today, and agentd gains the stateless one when the SDK does. configuration.blocking was never an A2A field; the spec spells it returnImmediately, and the listener translates so existing clients keep working.

Fixed (the A2A wire was not quite proto3 JSON — a second reader found it)

A2A is defined in protocol buffers, and its JSON binding is proto3 JSON. agentd followed that for task states (TASK_STATE_*) but had drifted elsewhere, in the way that is hardest to notice: every one of these parses fine as JSON and is rejected by a peer's generated types, in production, with no error we would ever see.

  • role is ROLE_AGENT, not "agent". Every agent-authored Message — task status, streaming status frames — used the English word. A peer built from the schema could not deserialize any of them. (The outbound A2A client already sent ROLE_USER, so the two halves of agentd disagreed.)
  • status.timestamp is an RFC 3339 string, not epoch milliseconds. It is a google.protobuf.Timestamp; an integer is a type error to a peer.
  • ListTasks returns Tasks. It was returning agentd's internal record — a flat state, plus principal/link/updated — so the one method whose whole job is enumerating tasks emitted objects a peer could not read as tasks. The result now also carries totalSize, pageSize and nextPageToken, which ListTasksResult requires.
  • Task.history is repeated Message, so agentd's state transitions no longer sit there pretending to be messages. Everything agentd wants to say that the spec has no field for now lives under metadata, namespaced: agentd/principal, agentd/link, agentd/statusHistory, agentd/created.

The TUI and web clients read the new shape and still accept the old one.

Added (an independent reader for the A2A spec)

  • crates/a2a-oracle boots the real daemon, drives its real A2A listener, and parses every response with a2a-rs — an unrelated implementation of the same specification, by a different author, with types derived from the published schema. Our own tests were written from our own reading of the spec and so cannot catch a plausible misreading; this is a second reader that can, and it is what found everything in the section above. It is deliberately outside the workspace (it brings ~180 crates, which have no place in the FROM scratch static build) with its own target dir, and runs as its own CI job: cargo test --manifest-path crates/a2a-oracle/Cargo.toml.
  • The conformance suite gained a2a-conversation/tasks-are-proto3-json-on-every-path, so the shapes stay fixed by the fast suite in ordinary CI.

Fixed (--validate-config missed workflow errors — and the docs had some)

  • --validate-config now parses workflow definitions, the same strict parse the runtime runs at startup. It used to check only the config around the workflows: a typo'd step field (prompt: on an agent node, which takes instruction) validated clean and then exited 2 on the first real start. A pre-flight check that passes what production refuses is worse than none — this is the split it existed to prevent. Structural errors are still reported first, so the more basic message keeps leading.
  • Audited every command and workflow sample in the docs against the binary (flags against --help, node fields against --workflow-schema, whole blocks through --validate-config) and fixed what was wrong: prompt: on agent steps, writes:/input: on agent (they belong to assign / nowhere), rate: on foreach (a batch field), min_success: on parallel (a race field), emit_url_to on wait (never existed), every: on loop (it is interval; every is schedule's), and debounce: on subscribe (it is debounce_ms). docs/scaling.md still taught the removed 1.x --mode/--continue/--claim flags for continue-claim; it now shows the 2.0 subscribe + deliver: wait + claim shape. Every workflow block in the docs now passes the real validator. (--model was suspected and is fine — it is a documented alias for intelligence.model.)

Changed (landing page: each idea in one place)

  • The MCP/A2A pitch appeared three times — hero, "shape of a run", and its own section — so the page repeated itself instead of building. The hero now says what agentd is, "shape of a run" covers the two-loop architecture, and the protocol section owns where capability comes from. The example model id was refreshed too.

Added (--prompt: a one-shot job, or a self-setup)

  • agentd --prompt "…" --intelligence <url> asks, answers on stdout, and exits — the shape people expect from an agent CLI. --prompt-file and AGENTD_AGENT_PROMPT work too, and --instruction still means the standing policy: give both and the instruction is the system prompt while the prompt is the task.
  • The prompt is delivered as a message into the agent's root context, not as a synthesized workflow step, and that distinction is the feature: workflow-authoring tools are root-scoped, so a prompt running as a step could never build what it was asked for. A prompt may therefore set the instance up — "check the queue every 30 seconds from now on" has the agent workflow.create its own loop/schedule/subscribe workflow.
  • lifecycle.run_until: auto now re-reads the live workflow set. It used to decide "job or daemon" once, at startup, from the configured workflows — so an instance that armed a long-lived workflow at runtime idle-exited out from under it moments later. A self-set-up agent now stays up, and a plain one-shot still exits.

Changed (the display clients ship as one package: @agentd-dev/cli)

  • Three npm packages became one. @agentd/client, @agentd/tui and @agentd/ui are now a single published package, @agentd-dev/cli, whose bin provides both agentd-tui and agentd-ui and whose library entry point is the shared thin-client core. A display client is one install, and the core can no longer skew against the UIs that render from it. interface/ is a plain package (src/{client,tui,ui}), not a workspace.
  • agentd tui --inline works. The passthrough forwarded only --debug and --no-open to the client, so the documented --inline fell through to the daemon and died with unknown argument: --inline.
  • The "cannot start the client" hint and agentd --help now name npm install -g @agentd-dev/cli.

Added (getting it onto a machine: installer, skill, security policy)

  • install.sh is served from the domain it advertises. The site build publishes the repo-root script at https://agentd.dev/install.sh (it used to 404), so curl -fsSL https://agentd.dev/install.sh | sh works. The script now matches what the release actually builds — it accepts arm64 Linux (which was refused despite being published) and no longer offers a macOS binary that was never built, pointing at a source build instead. It verifies the download against the release SHA256SUMS, takes --version / --dir / --no-verify as flags or AGENTD_* env vars, and refuses rather than half-installing when a checksum mismatches.
  • skills/agentd/ — an Agent Skill that teaches an AI coding assistant to install, configure, run and debug agentd, with reference/config.md and reference/coding-agent.md for detail. Drop it in ~/.claude/skills/. (Not to be confused with agentd's own skills: config, which discovers instruction bundles from MCP servers — RFC 0028 §7.)
  • SECURITY.md — private reporting, and an explicit boundary list: what counts as a vulnerability here (an exec fence escape, a secret in a log, a trifecta bypass, unauthenticated access to the A2A listener) versus what does not (a model using powers you granted it).

Added (-c as a short --config, and = value forms)

  • -c a.yaml, -c=a.yaml and --config=a.yaml now work everywhere --config a.yaml did, including through the agentd tui|ui passthrough. Previously only the space-separated long form parsed, so the documented agentd ui -c=code.yml failed with unknown argument.
  • agentd --help documents the tui and ui subcommands, which were implemented but invisible in the help text.

Changed (TUI renders fullscreen by default)

  • agentd-tui now takes over the terminal (the alternate screen), so the layout is stable and the shell is restored on exit. Because that buffer has no scrollback, the client owns it: PgUp/PgDn scroll a bottom-anchored transcript viewport, a hint reports what is above the fold, and new messages follow the tail unless you have scrolled up (then your position holds).
  • --inline (or AGENTD_TUI_INLINE=1) keeps the previous behavior — settled rows ride Ink's <Static> into the terminal's real scrollback and survive quitting. Non-interactive runs (pipes, CI) degrade to inline automatically (Ink gates the alternate screen behind an interactive TTY).

Added (live activity — RFC 0032 §17)

  • The working row now says what the agent is doing: thinking · 12s · 1.2k tok · round 2, read_file · 3s, waiting · subagent · 40s. The turn worker's coarse progress frames (AgentMsg::Event) were previously dropped by the supervisor; they now fold into a per-unit activity record (phase, tool, round, tokens, started_ms) published as activity / activity.removed feed events and mirrored in status.activity. New child-side signals turn.think / turn.tool (the only way an MCP tool call is visible to the supervisor — the child holds its own connections) and turn.round now carries per-round usage; a deferred tool parks the unit.
  • Deliberately coarse: events fire only on a change an operator would notice, and elapsed is never streamed (clients tick from started_ms), so a long think emits nothing and the feed's replay ring stays a record of state. activityLine() in @agentd-dev/cli renders it identically in both UIs.

Added (human-in-the-loop + steering — RFC 0032 §16, RFC 0029 §5/§7)

  • ask_human is real (was a stub): an ask — the model's tool call, or a workflow human step — flips the owning A2A task to input-required with the question as its status message; every attached display client renders an answerable gate, and a SendMessage carrying the taskId resolves the suspended asker with the reply (tool result to the model; step output to the workflow — later steps template on steps.<gate>.output). Answers broadcast on the feed; ask + answer are audited. Run-linked gates are rebuilt after a restart (durable HITL); turn gates degrade to conversation continuation. Cancelling a gate unblocks its asker with an error.
  • agent.ask_human_fallback when no human channel exists (and, for auto, when a rendered gate times out unanswered): fail (default), wait (park until the ask timeout), or auto — an LLM judge answers on the operator's behalf (conservative prompt, UNDECIDED ⇒ fail), always marked as auto in the task/log/audit.
  • Steering command ops now dispatch (were UNSUPPORTED_OPERATION): workflow.signal (resumes wait: {on: signal} steps), subagent.send (inject into a warm subagent), subagent.kill, subagent.status, plan.get.
  • a2a.pause / a2a.resume (operator): with {run}, flip one run between Paused and Running; without, hold the WHOLE instance — intake continues (inbox, tasks), no new turns dispatch and no steps schedule until resume. Reversible, unlike drain; a paused instance never idle-exits; status.paused + a lifecycle feed event keep every client honest (the UIs show PAUSED).
  • Clients: /signal /send /pause /resume /plan in both UIs (+ signal()/subagentSend()/pause()/resume()/planGet() on the client core).

Added (the display-client interface & observation plane — RFC 0032, docs/interface.md)

  • interface: config (default OFF): enabled serves the display-client surface on the existing A2A listener; debug exposes the extra reads; origins allowlists hosted web-UI origins (CORS + preflight; loopback origins always pass; every other cross-site origin stays 403).
  • SubscribeToEvents — the global observation feed (SSE): a seq-cursored, principal-scoped event ring (hello/event/goodbye) carrying task transitions (with artifacts), every NL prompt (message — the cross-client transcript), mutating commands, run/conversation/subagent/child/status section deltas (a 4 Hz fingerprint diff in the loop), lifecycle, and (debug) audit records. N attached clients converge with no client-to-client sync; reconnect resumes from the cursor.
  • Taskless interface reads (command ops that create no durable task): interface.info (discovery — the client keys its debug panes off the daemon), and under debug: conversation.get (transcripts with message bodies), run.get (per-step run detail), debug.events (the live log ring, revived from RFC 0016 §7.2 and installed when debug is on).
  • agentd tui / agentd ui (unix): run the daemon AND its display client as one command — forces --interface.enabled (argv, reload-safe), redirects daemon logs to a file, hands the tty to the client (AGENTD_TUI_BIN/ AGENTD_UI_BIN override), ties lifetimes (client exit ⇒ graceful drain).
  • interface/ — the display clients (a separate Node package, @agentd-dev/cli; the Rust 3-dependency moat is untouched): the shared thin-client core (wire, task normalization, the event-sourced Mirror, the Observation driver with automatic poll fallback), the agentd-tui Ink terminal UI (chat, tasks, daemon-gated debug screen; degrades to a read-only view without a tty), and the agentd-ui web UI in the format of the TUI (statically hostable, local server with --open).
  • The agent card advertises urn:agentd:interface when enabled; --capabilities reports the interface block and the extra methods/ops.
  • Pairing-code login (interface.pairing, RFC 0032 §13): the daemon derives a 6-digit code per 60 s window (HMAC over a per-process /dev/urandom seed); an operator reads it (pairing.code / the TUI's /pair), and an UNAUTHENTICATED client exchanges it (Pair {code} — the one anonymous method) for an in-memory session token (constant-time verify, previous-window grace, 5-fail/window lockout, configurable role operator|user + TTL, revoked on restart). Clients: agentd-tui --code, the web connect form's code field. On a non-loopback listener pairing counts as client auth (validation + 401→anonymous admission).
  • Daemon-driven client chrome (interface.display, RFC 0032 §12): ordered item lists for the top/bottom edges every attached client renders (name version instance model endpoint conn debug draining active turns tokens tool_calls runs subagents conversations screen keys clock); served in interface.info, defaults preserved, unknown items warn + are skipped.
  • Runtime config.set (operator, RFC 0032 §14): whitelisted knobs only — interface.debug (live debug toggle; installs the log ring) and interface.display.top|bottom — echoed as a config feed event so every client re-shapes at once; everything else names the whitelist and stays with the config file + SIGHUP (deliberate: no wire path forks config provenance).
  • subagent.get {handle} (debug): one subagent's drill-down — instruction, status, mode, attempts, tokens, truncated result/error, requested_by — behind the new subagents screen (TUI: list → Enter → detail → Esc back; web: clickable rows → detail → back), fed live by subagent events.
  • Composer affordances (shared client-core rules, both UIs, with as-you-type suggestions): / system commands plus every workflow as a shortcut; @skill catalogue completion (inline — agentd preloads references); leading #task-…/#ctx message targeting (answer a specific input-required gate / address a conversation); $model $instance $version $turns $tokens $tasks live-value interpolation ($$ escapes). New TUI commands: /pair, /set, /config [path], /subagents; status_value now carries model.

Added (configuration mechanism — RFC 0017 §3, docs/configuration.md §1.1/§12)

  • YAML config files. --config <file> accepts YAML (.yaml/.yml) alongside JSON/jsonc (.json/.jsonc; other extensions are sniffed). YAML is read by a hand-rolled, dependency-free subset reader (config::yaml) — mappings, sequences, flow collections, quoted/plain/block scalars, comments, YAML 1.2 core typing — into the same document the JSON path yields, so validation, --config-schema, --validate-config and hot reload (SIGHUP / --watch-config) are format-agnostic. Anchors/aliases, tags, merge keys, multi-document streams, tabs and duplicate keys are rejected with a line/column error. The default build stays at exactly three direct deps.
  • Path-derived env vars. Every config-file path is an env var named after the path: limits.max_steps ⇒ AGENTD_LIMITS_MAX_STEPS › AGENT_LIMITS_MAX_STEPS › bare LIMITS_MAX_STEPS (first present wins); values are typed by the schema (integers, enums, [a, b] / comma lists, {k: v} objects). Derived from the config schema, so a changed parameter set needs no per-field plumbing. The named AGENT_* variables keep working.
  • Generic path flags. Every config-file path is a flag too — --limits.max-steps 5 (also --limits-max-steps, --limits.max_steps), applied in argument order like any flag; a dotted flag reaches into a free-form map (--intelligence_headers.x-team ops sets one entry, exact spelling kept); agentd --help lists the full CONFIG PATHS table (path · flag · env). Precedence is unchanged: built-in < file < env < flag.
  • Multiple config files. --config is repeatable and AGENT_CONFIG takes a :-separated list; the files compose into one document in order, a later file overriding the earlier ones (JSON Merge Patch, RFC 7396: objects merge, scalars/lists replace, null unsets). Each file is type-checked on its own so an unknown key names its file; config.loaded lists the merged config_files; --watch-config watches every file.
  • Set vs add. Setting a path from env or a --<path> flag sets its value (a list/map path replaces the files' value); the named repeatable flags (--mcp, --subscribe, --a2a-peer) keep adding one element.

Added (agentd 2.0 track — in development, RFC 0025–0030)

  • Config schema v2 (config::v2, RFC 0030): the nested settings document (agent / intelligence / mcp / tools / store / memory / context / knowledge / search / skills / workflows / limits / lifecycle / a2a / observability / security / cluster) with a hand-written JSON Schema (--config-schema=2), path-derived env/flags (AGENTD_INTELLIGENCE_MODEL, --limits.run.steps), an alias table for the 1.x quickstart flags, v1/v2 detection, collected validation (--validate-config), and the --instruction sugar workflow. A v2 document selects the 2.0 runtime (below); --capabilities for a v2 configuration lands with the A2A v2 phase.
  • Durable state core (store, state; RFC 0025): the 4-op remote-store contract (put(key, seq, envelope) with a seq compare-and-set, get, list, delete) with adapters over MCP tools (the default checkpointer profile state.put/get/list/delete, or any server via template/CEL argument and result mappings), HTTP (templated REST with Idempotency-Key and secret-ref headers) and an in-process memory store; envelope v2 under <prefix>/<instance>/<kind>/<id>; the entity model (manifest with a live index + generation, write-ahead inbox, timers, contexts, runs, subagents, tasks, artifacts, memory, audit); the Durable façade — per-key sequence tracking, a conflict on an owned key is a fatal split-brain signal, debounced manifest flush, store.on_error: halt|degrade, and the restore protocol (manifest → indexed entities → list reconciliation → generation bump); dependency-free ULIDs. The built-in mock MCP server (--internal-mock-mcp-http) gained state.list {prefix}, state.delete, structuredContent results and mock.fault/mock.ops for chaos tests; AGENTD_TEST_KILL_AT=<point> (debug / internal-mocks builds) SIGKILLs the process at a named kill point.
  • The 2.0 runtime — agent loop v2 (runtime, registry, context, governor, engine, jsonschema; RFC 0026–0028, in development): a v2 configuration document (or --config-version 2 / AGENTD_CONFIG_VERSION=2 with the quickstart flags) now runs the new event loop — a single-writer reactor over durable state with a flat child tree of turn workers (one model turn per child; internal tools round-trip to the supervisor as ToolRequest/ToolResult; budget admission per model call) and subagents. Landed: the tool registry (every RFC 0028 §3 internal contract with input/ output JSON Schemas and grants; internal > code > MCP; tools.overrides map a contract onto any MCP tool with template/CEL args+result mappings, validated at startup; tools.disabled; knowledge/search profile servers); contexts (root + per-conversation durable transcripts, structured summaries, the conversation plan (plan.*), compaction by a summarizer think, skills discovered from MCP prompts/resources with @skill: preloading, memory KV with TTL, artifacts); the conversation preflight (agent.preflight) with status/clarify short-circuits, plan seeding and skill preloading; knowledge auto-context (knowledge.auto_context); the token governor (intelligence.budget: unit-aligned windows incl. calendar resets, requests + tokens, sub-budgets per conversation/run, tactics wait | slow | degrade | refuse | fail, the lifetime ceiling, counters durable in the manifest); the workflow engine core (dialect-3 model + strict validation over the full RFC 0027 catalogue, {{path | default}}/CEL: templates, durable run records, the scheduler with when guards and on_error fail/continue/goto, retries with backoff, once/manual start nodes, the step kinds noop checkpoint assign transform template validate assert fail emit finish sleep tool memory.* artifact.* knowledge.* search.* mcp.tool agent think — the rest of the catalogue lands with the P4 engine); the subagent registry (subagent.run sync/async/detached/warm, caps, the trifecta gate, durable records + re-spawn on restore); durable timers; restore of runs (running steps replayed with the same idempotency key), contexts, subagents, timers, artifacts and pending inbox events; the lifecycle (lifecycle.run_until: job shape ⇒ idle exit with the finish status mapped to the RFC 0011 exit code; daemon ⇒ SIGTERM drain via the kill ladder); hot reload of the v2 reloadable partition on SIGHUP / lifecycle.watch_config (intelligence, budgets, instruction, MCP servers, registry, skills, workflow definitions — live runs stay pinned to their hash; restart-only paths refuse); the instruction as static text or an MCP resource (read + subscribed, re-read on update); context.model_window. Test scaffolding: mock-LLM file:<playbook.json> scripts, mock-MCP knowledge.*/search.* profiles + skills as prompts/resources, AGENTD_TEST_INBOX_FILE (debug builds) to inject inbox events; e2e suites runtime_v2_e2e.rs (job through the loop, overrides, SIGKILL/restore across four lives, preflight+skills+knowledge conversation, status intent + subagent delegation, budget fail exit 7, compaction + restore) and runtime_v2_reload_e2e.rs (SIGHUP apply / restart-required / SIGTERM drain). A2A v2 intake/commands/principals now land the HTTPS listener (see the A2A v2 transport binding entry below). Not yet: the mode cut-over (removing the 1.x surfaces), --capabilities for v2, docs.
  • Workflow engine v3 — the full node catalogue + start-node triggers (RFC 0027, in development): the dialect-3 workflow language now executes end to end through the 2.0 runtime. Nested bodies — foreach/batch (bounded parallelism, rate pacing, per-batch durable progress that resumes after a crash, positional collect, on_error: continue element slots, batch.by grouping, artifact-backed item lists), iterate (while/until/ max_iterations), parallel (fan-in object, min_success), race (first-branch-wins with cancellation + timeout), subgraph; switch routing; the pure data steps map/filter/reduce/sort/dedupe/ chunk/parse (CEL or {{…}} element expressions; CSV/YAML/JSON/lines); orchestration steps wait (on a resource update, a CEL condition, a signal, a run, a subagent, a conversation message or a deadline), join, workflow (a child run — sync/async/detached with cascade), workflow.signal/wait/cancel, subagent, human, mcp.resource (read/list/prompt/complete/templates) and a2a.delegate (the RFC 0020 client, feature-gated); the think presets classify/extract/summarize/judge/ route; and per-step cache (memoized by input hash). Large step outputs spill to artifacts ({"$artifact": id}) and dereference transparently in templates. Start nodes as triggers: loop (re-run on completion with interval/until/max_iterations/backoff), schedule (5-field cron / every / one-shot at), subscribe (an MCP resource update, notify-then-read, debounce/coalesce/filter), signal and event (workflow.finished/ failed) — start-node state (last fired, iteration, next deadline, debounce) is durable in the manifest, and the reactor tick is now adaptive to the nearest deadline so time-based work fires promptly. The run registry gained concurrency policies (queue/drop/replace), pause/resume, cascade cancellation, the workflow.* tools and --workflow-schema; restored runs replay their in-flight steps with the same idempotency key.
  • A2A v2 transport binding (RFC 0029; --features a2a) — the 2.0 daemon's only external channel. When a2a.listen is set, the runtime binds the real HTTPS listener and turns A2A JSON-RPC into runtime work: principals & roles (mTLS / bearer → operator | user | agent | anonymous) with a may / may_command authorization matrix; natural-language messages become conversation turns whose answer lands as the task artifact; command DataParts ({"data":{"agentd":{"op":…}}}) run status and workflow.run / workflow.status / workflow.cancel; durable tasks (Kind::Task) that survive a restart, with GetTask / ListTasks / CancelTask (ownership scoped) and SendStreamingMessage / SubscribeToTask streaming working→status/artifact→terminal frames; the operator admin family (a2a.drain / lameduck / cancel) and a public GetAgentCard (workflows advertised as A2A skills). The listener runs off the single-writer loop — requests cross as a new loop event and blocking/streaming reads are served from a shared task snapshot the loop keeps current, so the loop never stalls. A task started by workflow.run tracks its run to completion. Built additively beside the 1.x surfaces (removed at the P5 cut-over). Current limits: the serve framework surfaces only whether a client cert was presented (no SAN/subject), so san/sub principal matchers need a bearer and mTLS conveys operator until the subject is exposed; command DataParts cover the status/workflow.* subset (the NL path reaches every internal tool); pause/resume admin is a stub. The default build is unaffected (feature-gated; deps unchanged).
  • Audit stream (plan §3.11) — an append-only who-did-what trail: {ts, instance, principal, role, action, target, outcome, request_id, trace} to the configured observability.audit.sinks — log (a closed-vocabulary audit log line) and/or store (a durable, ULID-keyed, append-only Kind::Audit record that is never rewritten). Every A2A call is audited with its principal, method + command op, and outcome; config reloads and durable restores (with the lost-entity count) are audited too. Off by default (no sink configured ⇒ no overhead).
  • Observability serving in the 2.0 runtime. The Prometheus /metrics surface (observability.metrics_addr, metrics feature) and the health-file liveness heartbeat (observability.health_file) are now started by the 2.0 runtime (their 1.x wiring was removed with the mode cut-over).
  • OTEL traces from the 2.0 turn worker (otel feature): a turn opens an invoke_agent span with a chat child span per model call and an execute_tool child span per tool call, exported as one OTLP trace — matching the 1.x agent loop's tracing on the 2.0 path.
  • OTLP logs export (otel feature, observability.otel.logs): the JSON-lines log surface is mirrored to <endpoint>/v1/logs by a bounded background buffer, hooked at the logger's single emit point (a no-op when unarmed).
  • Per-child cgroup placement in the 2.0 runtime (security.cgroup): the runtime arms the process-tree cgroup at startup, and every spawned child (turn workers + subagents) is placed in its own leaf with the configured memory.max/pids.max and cgroup.kill atomic teardown on reap.
  • v2 runtime metrics (plan §3.11): agent_turns_total{kind}, agent_steps_total{status}, agent_store_ops_total{result} + agent_store_latency_ms_sum, and the agent_inbox_pending / agent_context_tokens gauges — bounded closed-label series on the existing /metrics surface.
  • agent:// read surface over A2A. The A2A status command already returns a comprehensive runtime view (store, workflows, runs, conversations, subagents, timers, inbox, budget, tools, skills, counters); added a config command that returns the effective merged configuration (secret references, never values), operator-gated.

Changed

  • The mode cut-over: agentd 2.0 is v2-only. The 1.x mode drivers and the flat v1 schema are gone (~32,000 lines removed). The CLI routes every configuration through the 2.0 runtime (runtime::run); a 1.x document — the flat schema or a --mode invocation — is rejected at the gate with a migration hint (exit 2). Removed the entire v1 surface: the mode drivers (once/loop/reactive/ schedule/workflow), the v1 self-MCP server and its v1 A2A binding (replaced by the RFC 0029 A2A v2 listener; the A2A client wire helpers were preserved), the v1 capabilities manifest + run-report backends (replaced by --capabilities over the v2 loader + the durable A2A task model), RFC 0019 cluster sharding, the v1 cyclic-workflow graph engine (superseded by the v3 engine), the in-child orchestrator + the v1 supervisor reactor/gate/restart/swap, and the v1 lifetime budget ledger (superseded by the v2 governor). Subagents are now RFC 0026 flat-tree leaves — a subagent runs a ReAct loop over its granted MCP/code tools and reports its result, with no in-child nesting/scheduling/delegation (that is the reactor's job). The serve-mcp / serve-https / events build features were retired (a2a now rides tls directly — the v2 listener needs no v1 server); cel no longer implies workflow. Zero change to v2 behavior (verified by the full v2 e2e suite, incl. subagent spawn/delegation, staying green). The black-box conformance suite was reduced to its v2-viable families (supervisor, security); a full v2 conformance rebuild (A2A, the durable store, the v3 engine) is planned. The now-unreachable v1 Config (and config::Mode + the vestigial workflow feature) remain as dead code, slated for a follow-up deletion.
  • config.rs / config_file.rs / config_watch.rs moved into a config/ module directory (config::{file, yaml, paths, watch}); the public agentd::config surface is unchanged. Config-layer error messages now name their source (config file / env / the flag).

v1.4.0 — OpenAI-compatibility fixes + AAuth provider/token validation

Real-provider hardening. Proving the eval harness (RFC 0024) against live OpenAI surfaced three genuine compatibility bugs in the intelligence dial — all fixed — and the AAuth agent-identity client gained the provider/token validation the protocol calls for. The stock binary's behavior is otherwise unchanged; crates step to agentd / agentd-cli 1.4.0 (library crates agentd-mcp / agentd-net stay 0.3.0 — unchanged).

Fixed (intelligence dial — OpenAI/Anthropic compatibility)

  • Dotted tool names are sanitized on the wire. OpenAI/Anthropic reject tool names that aren't ^[a-zA-Z0-9_-]+$, but agentd uses namespaced names (resource.read, subagent.spawn). Each name is now made wire-safe on the outbound request (in the tool defs and the assistant message-history tool_calls) and reverse-mapped on the response, so tool-calling against real OpenAI works and routing is unaffected. No-op when every name is already legal.
  • Reasoning models use max_completion_tokens. gpt-5 / o-series reject max_tokens; the request now selects the correct token-limit parameter by model, so those models produce tool calls instead of an HTTP 400.
  • Transient 429/5xx are retried before failing. complete_once now retries a momentary provider blip on the same endpoint (bounded, short backoff) before the error propagates. Previously, once mode — which arms no higher-level retry loop — surfaced a single transient error as an immediate exit 4. A non-transient 4xx (bad request, auth) still surfaces immediately.

Added (AAuth [DRAFT], RFC 0023 §7.1 — --features aauth)

  • Agent-Provider metadata discovery + issuer validation. At startup agentd fetches /.well-known/aauth-agent.json and enforces the AAuth protocol's anti-host-poisoning rule (a document whose issuer ≠ the configured provider aborts enrollment). Best-effort: a provider that publishes no document works.
  • Agent-token claim validation. The agent token is acted on rather than held opaque: agentd refreshes against the token's own exp, and fails fast if the token's iss isn't the configured provider, its ps isn't the configured Person Server, or its cnf.jwk isn't the signing key — each a misconfiguration that would otherwise be a silent wall of downstream 401s.

Tooling

  • Evaluation harness (RFC 0024, bench/). A dependency-free (stdlib) rig that drives the real binary per task and grades it: BFCL tool-calling with a faithful AST value-matcher, a τ²-bench-style simulated-user conversation loop, an execution-graded coding suite, a sandboxed shell/file environment, and a workflow-lift ablation — plus a model×benchmark matrix, run live against OpenAI. Not part of the shipped binary.

v1.3.0 — library split, code-registered tools, AAuth agent identity & lifetime budgets (RFC 0022/0023/0025)

agentd is now consumable as a library. The workspace splits into four publishable crates around one engine; the stock binary is behaviorally unchanged. Library crates step to agentd-mcp 0.3.0 / agentd-net 0.3.0 alongside the 1.3.0 binary.

Added

  • Crates: agentd-core (the engine — lib name agentd), agentd-cli (the thin binary shell producing the agentd command), agentd-mcp and agentd-net (the already-reusable MCP + transport libraries, renamed for crates.io; the bare name agentd on crates.io belongs to an unrelated project). Feature graphs are isomorphic between core and cli; CI gates both per matrix row.
  • Code-registered tools (agentd::tools, RFC 0022 §4): an embedder registers native Rust tools (CodeTool::new(name, description, schema, handler)) that join the model's catalogue, are addressable from workflows as the reserved server code, and are callable via the public tools::call. Dispatch precedence self → code → MCP: the orchestration surface is unshadowable and a remote server cannot steal a first-party tool's calls (ToolClass::Code). The stock CLI registers nothing — its no-local-code posture holds by construction; the manifest surfaces surfaces.code_tools only when non-zero.
  • The compile-guaranteed embedder reference: crates/agentd/examples/custom-cli.rs (built by CI, runs offline); docs/embedding.md; RFC 0022 with the three API-stability tiers.

Added (AAuth [DRAFT], RFC 0023 — --features aauth)

  • Agent-side auth for calling AAuth-protected MCP servers: an Ed25519 agent identity (agentd::aauth::AgentKey), an Agent-Provider enroll + agent-token client with cache/refresh (ApdClient), and RFC 9421 HTTP Message Signatures on every outbound MCP request — no shared API key. Wired via a dependency-free RequestSigner seam in agentd-mcp (the crypto stays in agentd-core; ring is a direct edge only under aauth, the same crate rustls already links). One identity per process tree (rides the spawn payload). Config: --aauth-provider / --aauth-key-file / --aauth-enroll-token / --aauth-person-server (+ AGENT_AAUTH_*). Manifest: surfaces.aauth.
  • Now ships in the release binary and container image (the aauth feature joined the default release feature set). Its crypto (ring) is already linked by the default tls/rustls transport, so shipping it adds no new crate to the graph — unlike cel, which stays build-from-source. Still a compile-time feature (a trimmed build can drop it) and still [draft].
  • The transport runs the full request reaction loop (sign → inspect AAuth-Requirement/AAuth-Access → satisfy → re-sign → retry, bounded), so all three access modes run end to end: Case A (identity-based), Case B (adopt a returned AAuth-Access token and replay it), and Case C (user-scoped — the Person-Server resource-token → user auth-token exchange, presented as the Signature-Key). Plus discovery (/.well-known/aauth-resource.json), content-digest covering when a server requires body integrity, and per-server opt-out (aauth: false on a --mcp config entry).
  • Federated enrollment (--aauth-enroll-assertion-file / AGENT_AAUTH_ENROLL_ASSERTION_FILE): the enroll body carries an enrollment_assertion (e.g. a Kubernetes projected ServiceAccount token), re-read fresh on every enroll so a rotated token is always current — the secret-free fleet path (no shared enrollment secret, no operator key custody).
  • The intelligence dial is signed (agentctl RFC 0024 §7.1): with an identity installed, agentd applies the same RFC 9421 headers to its --intelligence requests (chat POST + /v1/models GET), so a model gateway can attest the individual agent by signature instead of source IP. Additive — the bearer, if any, still rides alongside.
  • Proven by four live-socket e2e tests (aauth_e2e Case A; aauth_flow_e2e Case C over a real McpClient; aauth_enroll_assertion_e2e federated fresh-read; aauth_intel_sign_e2e signed model dial). Draft: the wire tracks the AAuth guide agentd was built against and may shift.
  • The hwk Signature-Key (enroll + single-key token refresh) presents the raw Ed25519 key as inline kty/crv/x structured-field params per draft-hardt-httpbis-signature-key — not a jwk="<b64url JSON>" blob, which a conformant Agent Provider rejects (invalid_key). Verified by driving the real binary against a real Agent Provider end to end (the mock-AP unit tests do not parse the hwk form, so this surfaced only under real integration).

Added (harness-tracked budgets, RFC 0025)

  • Per-instance lifetime token budget (--budget-tokens-lifetime / AGENT_BUDGET_TOKENS, config-file limits.lifetime_tokens): a cumulative token cap across all runs/reactions of an instance, distinct from the per-run --max-tokens box. Bounds a long-lived agent on a path with no metering gateway (e.g. an AAuth direct dial). 0/unset = unbounded (today's behaviour). A bounded once run folds min(max_tokens, cap) and trips EXIT_BUDGET(7); a reactive/loop/schedule daemon stops accepting new work and drains cleanly (exit 0, or --budget-exit-code) with a budget.exhausted event. Metered where all child tokens converge in the supervisor.
  • Observability (metrics_schema → 1.1, additive): the gauge agent_budget_tokens_remaining (the alerting/scaling hook) and the tokens_lifetime value of agent_limit_exceeded_total{limit}; a one-shot limit.threshold event fires the first time usage crosses 90% of the cap.

Changed

  • --mcp code=… is refused (code is the reserved code-tools server name).
  • Building from source: the binary is now cargo build -p agentd-cli (release artifacts unchanged).

v1.2.0 — workflow dialect 2: durable, parallel, human-in-the-loop workflows (RFC 0021)

Workflows now match — and in places exceed — the code-first agent SDKs, while staying a declarative JSON artifact. Zero new dependencies (the moat holds at 3). contract_version stays 1.0; feature-detect via surfaces.workflow.dialect >= 2.

Added

  • Human gates over A2A (human node): a workflow suspends and asks a person — the served task projects TASK_STATE_INPUT_REQUIRED with the payload as its status message; the reply is a spec-native SendMessage carrying the taskId (the A2A multi-turn shape — no agentd-specific API). Reply / reply_uri update / timeout race, first wins. Duplicate reply → -32004, unknown task → -32001; degrades to a plain wait without serving.
  • The MCP checkpointer (checkpoint graph policy): per-superstep durable run state, with the checkpointer as any MCP server implementing state.put/state.get/state.list (monotonic-seq guard; a refused put is always fatal — the split-brain protection). --workflow-resume <server>:<key>[@seq] (+ AGENT_WORKFLOW_RESUME, --workflow-resume-force): crash-recovery from the latest envelope (exactly-once for completed nodes, at-least-once for the in-flight one), @seq under a new run-id = a fork, hash-mismatch = refusal (exit 5). Budgets carry over across resume. Envelopes bind the graph by SHA-256 (hand-rolled FIPS 180-4, NIST-vector tested).
  • Write reducers (writes_mode: overwrite|append|merge|union on every writing node): accumulate instead of clobber; pure, clamp-aware, type mismatch → the error edge with a readable marker.
  • The parallel node: named heterogeneous branch bodies run concurrently on the SAME 8-lane pool foreach uses (composition never multiplies concurrency); ≤16 branches, step pre-charge, shared token pool, results as one object keyed by branch name, fail_fast|continue.
  • Manifest: surfaces.workflow.{dialect: 2, checkpoint: true, kinds: [12]}.

Changed (fail-closed hardening)

  • Unknown workflow fields are define-time errors (a typo'd writes_mode can no longer silently mean overwrite): one strict parse_graph front door behind --workflow, workflow.define, and workflow.patch. Dialect-1 graphs are byte-identical on the wire and behaviorally unchanged.
  • A2A SendMessage now accepts message.taskId as a gate-reply continuation of an existing task (-32004 when nothing is waiting).

Verified

Two new real-process e2e suites: a --mode workflow run SIGKILLed mid-node resumes from its checkpoint on a real HTTP checkpointer and completes with the pre-crash blackboard; a served A2A task flows WORKING → INPUT_REQUIRED → reply → COMPLETED on the wire. 686/686 featured tests (36 new), 384/384 default, conformance 38/38. RFC 0021 (Implemented) is the normative spec; docs/workflows.md the guide.

v1.1.0

Added

  • Bare env spellings for the two required inputs. INTELLIGENCE is now accepted alongside AGENT_INTELLIGENCE (mirroring the existing bare INSTRUCTION), so the minimal launch is INSTRUCTION=… INTELLIGENCE=… agentd. Precedence within the env layer is by specificity — branded AGENTD_* > neutral AGENT_* > bare — so a prefixed spelling always wins over the bare one. Additive; no existing spelling changes meaning. contract_version stays 1.0.

Fixed

  • AGENT_INSTRUCTION is honoured. It was silently ignored (de-branded to an AGENTD_INSTRUCTION nothing read), so following the neutral AGENT_* convention for the instruction produced a confusing "missing instruction" error. It now works like every other AGENT_* key.
  • Docs/site consistency sweep (post-1.0.0-reset): five run modes everywhere (the modes page gains the workflow mode row); exit 124 correctly attributed to the supervisor hard-kill backstop (a self-detected --deadline is 7); stale stdio-era claims in architecture.md ("MCP servers over stdio", "gated exec") rewritten to the remote-HTTPS / no-exec reality; one stray agentd:// → agent://; wire/log example versions → 1.0.0; the landing page's workflow card lists all ten node kinds (join was missing); use-cases.md added to the docs index.

v1.0.0 — first public release

The first public release of agentd: a small, MCP-native, HTTPS-everywhere agent runtime built for Kubernetes. It takes an instruction plus tools from remote MCP servers and runs the agentic loop — as a one-shot, a loop, a schedule, a reactive daemon, or an agent-authored workflow — supervised, bounded, and observable.

agentd is the reference implementation of the neutral Agent Control Contract (ACC 1.0). It is named agentd (the daemon) but speaks the neutral agent contract, so the agentctl control plane drives it without depending on this binary: agent:// resources, the agent_ Prometheus metric prefix (metrics_schema 1.0), the AGENT_* env/config convention, and a --capabilities manifest carrying contract_version 1.0.

Runtime

  • HTTPS everywhere. Intelligence, the MCP client, the served self-MCP, and the A2A / operator control surface are all HTTP(S) over mTLS; plaintext http:// is a loopback-only dev carve-out. There is no unix, vsock, or stdio transport and no local execution surface — the only process agentd starts is itself (a current_exe() re-exec for subagents). TLS (rustls + ring, bundled roots) is a default feature.
  • Remote MCP tools. --mcp name=<https://host/mcp> (Streamable HTTP: sessions + SSE, multi-version negotiation). Per-server auth is secret-free — header templates ({{secret:NAME}}), an mTLS client identity, or OAuth 2.1 client-credentials (--features oauth).
  • Serving requires identity. --serve-mcp https://host:port with --serve-cert/--serve-key; a non-loopback listener MUST authenticate peers (--serve-client-ca mTLS and/or --serve-bearer). Verified identity — never the transport — mints the Management origin.
  • Run modes: once, loop, schedule, reactive daemon, and workflow. Reactive subscriptions support content conditions, an in-turn await_resource wait, and live warm-session tool-catalogue refresh on tools/list_changed.

Workflows

  • Agent-authored workflows (--features workflow, dependency-free): an explicit cyclic graph the agent defines and drives — workflow.define / workflow.run (sync or detach into a supervised child) / additive workflow.patch, or the operator-pinned --mode workflow --workflow <file>. Ten node kinds (agent, tool, assign, infer, branch, foreach, join, subgraph, wait, halt) with layered, attributed termination (a step budget, a shared token pool, a wall-clock deadline, per-node visit caps, and a progress guard).
  • Reactive-daemon workflows (--mode reactive --workflow <file>): waits hold no process — the child suspends with its serialized run slice and a fresh child resumes on update/timeout, the budget continuing across processes.
  • CEL (--features cel, the one dependency-bearing opt-in): compile-checked, fail-closed predicates, computed assign.expr, infer.check constraints, and reactive wake conditions.

A2A

  • Real agent-to-agent interoperability over HTTPS with the bare PascalCase method binding (SendMessage, GetTask, CancelTask, ListTasks, SendStreamingMessage, SubscribeToTask); SendMessage returns the {"task": <Task>} envelope; SSE streaming terminates on the terminal task state and stream close. Peer client-auth via bearer header templates and/or a presented mTLS identity.
  • Operator control is the a2a.* method family — a2a.Drain, a2a.LameDuck, a2a.Pause, a2a.Resume, a2a.Cancel — Management-gated JSON-RPC methods (refusals as protocol errors).

Cloud-native contract

  • The frozen exit-code table (a clean drain is 0, not 143), the run-outcome report, the metrics schema (metrics_schema 1.0), the agent://events stream, liveness/readiness probes, --budget-exit-code, horizontal scaling (sharding + work-claim leases + standby), and SIGHUP/inotify hot reload of the reloadable config subset.

Security

  • The lethal-trifecta (Rule-of-Two) gate as the single validate() authority, and structural secret-freedom: no credential ever reaches the capabilities manifest, the config file, or the identity path. The served MCP endpoint is hardened (Origin validation as a DNS-rebinding defense; a per-initialize Mcp-Session-Id).

Conformance

  • Every contract surface validates against its schema and behaves as specified — see CONFORMANCE.md.