Skip to content

Roadmap

What is not yet built, and what shipping it would take. Everything else in the documentation describes the system as it exists today; this is the only file that talks about the future. If a capability is described here, assume it is not available in the current binaries.

For what is shipped, see ARCHITECTURE.md (design) and the USER-GUIDE / OPERATIONS (usage).


Priority

The sections below are ordered. The ordering is a judgement about sequence, not a schedule, and it follows three rules: something that returns a wrong answer today outranks something that returns no answer; something that makes other work safe to ship outranks the work it protects; and a gap that only appears at scale outranks one that only appears at the edges. Three entries have since shipped: a truncated run reported as a finished one, scheduled tasks, and evaluation.

Before running more than a handful of agents in production.

  1. Bounding cost — arrival is bounded, consumption is not. A runaway agent inside its session quota is visible, not stopped.
  2. Concurrency: two serial paths — the wall a ten-agent installation hits. Sequenced after cost, because concurrency multiplies spend at the same rate it multiplies throughput.
  3. Draining in-flight work on shutdown — a restart costs a caller the answer to a request the server accepted, indistinguishable from a crash.
  4. Per-host egress filtering on Linux — reaching the gateway broker on Linux today requires network = ["*"], which drops every egress restriction. It fails loudly rather than silently, which is why it is here and not above.

Deepening the trust story.

  1. Per-agent action signing — the keyed-chain half is much cheaper than the per-message signing half and closes the larger hole; worth splitting.
  2. Approval: work that waits for a person — accountability is entirely retrospective today. The daemon-driven half is buildable now; the synchronous half waits on the next item.

Protocol depth — when a peer integration actually asks for it.

  1. A task identity of its own — unblocks long-running work, and with it the synchronous half of approval.
  2. Consuming A2A: threaded calls — a correctness requirement, but only for installations holding multi-turn conversations with peers.
  3. Token streaming — presentation. The protocol surface is already in place for it.

Last.

  1. Authoring the harness — unblocked now that evaluation exists to score a rewrite. The permission suggest half is separable and cheap, and depends on none of it.
  2. Git-backed knowledge versioning — the workaround (keep the knowledge tree in git yourself) is close enough to the feature that this stays last.

Bounding cost: quotas at two chokepoints

Session admission is bounded — milieu api quota caps how many conversations a principal may hold open and how fast it may start new ones, over both the REST and A2A endpoints. That bounds arrival. What it does not bound is what a conversation goes on to cost once admitted, and nothing else in milieu is ever refused for being too expensive. That serves accountability — the hash-chained log says exactly what happened — but not containment: a runaway agent that is already inside its session quota is visible, not stopped.

Two further costs accumulate, and they do not share an enforcement point.

Model tokens. The audit log records tokens, never money, and audit report values them at render time from var/audit/prices.json — which is why a corrected table restates every historical report. This is the cost that actually grows, and the gateway cannot bound it: skillrun calls llm.Resolve and the backend directly, so a brokered-call chokepoint never sees an LLM request. The enforcement point is the run path, and the counter belongs with the audit state the main process owns, not with the gateway.

Brokered upstream calls. These do cross the gateway, and it already writes its own audit of them — but they are not priced and largely cannot be: milieu does not know a third party’s tariff. What is bounded here is therefore volume and rate, not money, unless an operator declares a nominal unit cost per route. It is the weaker of the two for containment and the stronger for blast radius — a loop hammering a partner’s API is a relationship problem before it is a bill.

The gateway half needs the gateway’s first durable state:

$MILIEU_HOME/var/gateway/state.db      # SQLite via platform/sqlitex, owner-only

It belongs beside ca/, server/ and audit.jsonl because the gateway process owns that directory exclusively, and because the gateway is meant to run in a trusted zone the agent sandbox cannot reach — possibly on another host. Anywhere under var/agents/ would hand an agent the ability to rewrite its own counter. sqlitex.Open already enforces owner-only mode on the database, its -wal/-shm and the parent directory, for the same reason it does on api/keys.db: write access here is the authority to lift your own cap. Note the gateway is not stateless today — the MCP backend keeps a negotiated Mcp-Session-Id and a handshake flag per route — but none of it survives a restart, and an in-memory counter that resets on every bounce is not a limit.

The split that keeps it reviewable is limits in config, counters in the database. Limits are declared in gateway.toml — and therefore in the manifest, and therefore reconciled by apply — alongside the routes they bound. state.db holds only what has been consumed.

Two design questions it turns on. What a call over budget does: refusing is honest but turns a spend cap into an outage, and an agent cannot tell the two apart unless the error says so explicitly. And whether limits are per-agent, per-route, or the pair — the pair is the useful one, and also the one that lets a misconfigured route starve an agent’s other work.

Concurrency: two serial paths

Milieu does one thing at a time in two places where it does not have to, and both become visible at the same modest scale.

Tool calls within a run. serveTools in internal/service/skillrun/loop.go walks an assistant turn’s tool_use blocks in order and resolves each before starting the next. Providers return parallel tool-use blocks precisely so a client need not do that, and the cost lands hardest on the multi-lookup turn that the code tool exists to collapse — three independent knowledge searches take three round-trips of latency for no reason. The ordering constraint is only on the results: a tool_result must come back for every tool_use in the turn, in a shape the model can match by id, which it does by id and not by position. What blocks this is not the fan-out but the audit and permission paths underneath it: they are written as though one run touches one tool at a time, and a sandboxed subprocess, a builtin, and an MCP call have different concurrency properties.

Agents within a daemon tick. daemon.cycle routes every outbox and then wakes every agent, strictly in sequence. One agent’s slow model call delays every other agent’s inbox, bounded only by --wake-timeout — so the worst case for a ten-agent installation is ten times the worst case for one. Fixing it is a bounded worker pool over the wake phase, and the bound is the interesting part: concurrent runs multiply spend at exactly the rate they multiply throughput, which ties this to Bounding cost. The routing phase should stay serial — it is cheap, and it is the step that makes the ordering of a conversation deterministic.

Neither is a correctness bug today. Both are the reason a working demo stops feeling like one at ten agents.

Draining in-flight work on shutdown

The network servers shut down gracefully: on SIGINT/SIGTERM they stop accepting connections and give in-flight requests 10 seconds (platform/httpx.ShutdownGrace) to finish. What that grace bounds is how long a deploy or a restart may block — it is not a promise that the work completes. A synchronous skill run can easily outlast it (there is deliberately no WriteTimeout, because capping how long a response may take is the one thing these servers cannot promise), and a run still going when the grace expires is cut off mid-flight. The caller sees a dropped connection.

Nothing is corrupted by this — a skill run’s effects are already recorded as they happen, and the audit and session records are written before the reply is returned — but the caller loses the answer to a request the server accepted, and has no way to tell that from a crash.

Shipping a real drain means deciding three things:

  • How long to wait. A fixed grace generous enough for the longest skill run is too long for a restart. This wants to be configurable per server (--shutdown-grace), with the supervisor’s own stop timeout raised to match — systemd kills at TimeoutStopSec regardless of what milieu is waiting for.
  • What to do with the requests that still do not finish. Answering 503 before the connection drops at least tells the client the server is going away rather than that the network failed.
  • Whether to shed load first. Failing readiness (and letting a load balancer drain the server) before refusing connections would let a rolling restart finish in-flight work without holding the whole deploy open.

Until then, treat a restart as something that can interrupt an in-flight run, and prefer restarting when the API is quiet.

Per-host egress filtering on Linux

On macOS (sbexec) a restricted [network] allowlist is enforced per-host by a loopback egress proxy. On Linux (bwrap) egress is all-or-nothing: a restricted policy yields no network, and full access needs network = ["*"]. Per-host filtering on Linux (e.g. an nftables/iptables egress layer) is not yet implemented. See SECURITY → Network egress.

This limitation also reaches the gateway broker. An agent’s broker proxy listens on the host loopback, but bwrap’s --unshare-net puts the agent in its own network namespace whose loopback is not the host’s — so a gateway-enabled agent under any non-["*"] policy cannot reach its broker on Linux. To avoid a silent failure (and the footgun of “fixing” it by granting ["*"], which drops all egress restriction), the bwrap backend now fails fast with a clear error in that case, and milieu lint warns ahead of time. So on Linux today, reaching the broker requires network = ["*"], or running the agent on macOS (sbexec), which permits only the loopback broker port. Lifting this needs the same per-host egress layer above — which would also let the namespace reach just the loopback broker/proxy ports while denying other egress.

Per-agent action signing

The audit log is tamper-evident via an unkeyed SHA-256 hash chain (audit verify), and agents authenticate to the gateway over mTLS and to the API via bearer keys. Two things are not yet implemented.

A keyed chain. Because the chain is unkeyed, anyone who can write to audit.jsonl can also recompute it end to end and leave a chain that verifies. It defends against accidental corruption and against an editor who does not rewrite the tail — not against a determined operator on the same host. An HMAC over an operator-held key would close that, and the design question it turns on is where the key lives: a key stored beside the log it protects buys very little, so it wants an env-supplied secret or a key file outside MILIEU_HOME, plus a decision about what a writer does when the key is absent.

Per-agent signing. There is no per-agent signing key that signs each outbound message and audit record, with the router verifying the signature before delivery. Until then, accountability rests on the hash-chained log and the identity captured at each trust boundary (API key → agent, gateway client-cert CN → agent), not on per-message cryptographic signatures.

Approval: work that waits for a person

Every action an agent takes is either pre-authorized or refused. Permissions are a static allowlist checked at invocation, the gateway authorizes a route by client-cert CN, and a denial comes back to the model as a tool error. Nothing escalates. There is no way for an operator to say that a particular class of action — a gateway route that moves money, a message to an external A2A peer, a write to the global memory layer — must wait for a person before it runs.

For a system whose principles are Observability, Explainability and Accountability, accountability today is entirely retrospective: the log is excellent at saying what an agent did and has no vocabulary for what an agent was not allowed to do yet.

The mechanism is most of the way there. The session tool already suspends a run as input-required and the boundaries already render that — a 409 over REST that keeps the question and the contextId, an input-required task over A2A. What that machinery addresses is the client, the party on the other end of the conversation. An approval addresses the operator, who is not in the conversation at all and may not be present for hours.

That difference is the whole design problem, and it splits by run shape:

  • A synchronous run cannot wait for a person. The request would have to outlive its connection, which is the same constraint that makes inbound tasks/cancel return -32002 honestly and that A task identity of its own exists to lift. Approval on the synchronous path can only mean refusing with a distinguishable “this needs approval” answer and a handle to grant it.
  • A daemon-driven run can wait, because nothing is holding a connection — but a message parked pending approval needs somewhere durable to park, a way for an operator to see the queue, and a decision about what MaxReplyDepth and the retention sweep do to a message that has been waiting three days.

Where the approval is declared is the other question. Attaching it to a gateway route puts it in the trusted zone with the secret, which is the right blast radius and the wrong place to see an agent’s pending work. Attaching it to a permission rule keeps it beside the grant it qualifies, and means the rule engine grows a third outcome — allow, deny, and ask — which every caller of permissions.Allowed then has to handle rather than ignore.

A task identity of its own

A2A tasks are derived here: a task is one exchange in a session, with the id <contextId>/<turn>. That is enough for message/send and tasks/get, and honest about tasks/cancel (-32002: a synchronous run is over before a peer could cancel it). It is not enough for long-running work — no working task a client can poll across requests, no canceled, no failed task persisted for later inspection, and no push notifications. Those need a task store and a run that outlives its request.

Consuming A2A: threaded calls

Milieu consumes A2A one exchange at a time. The gateway’s a2a backend speaks message/send — the synchronous half — so an agent can put a question to a peer without holding its credential, and a peer’s input-required, rate limit or failure each arrive as a distinguishable answer rather than a generic error. A contextId supplied by the caller is forwarded and the peer’s is always returned.

What is missing is anything that remembers that id.

A follow-up cannot happen within a run — a run is synchronous and ends, the same constraint that makes inbound tasks/cancel return -32002 honestly. It can happen across runs, which is what a contextId is for, and today that means the id has to travel through the agent: into a prompt, out of a reply, back into the next call. That is exactly the thing models drop or mangle, and a lost contextId does not error — it silently forks the conversation with the peer, discovered late and from the other side.

The fix is a thread map keyed (agent, route, name), where the agent names its own thread and the gateway resolves the name to the peer’s contextId. The opaque token then never enters a prompt. It is the second tenant of the gateway state.db described above, and a brokered call that both records a contextId and charges a route quota lands in one transaction — which is why the two want one database rather than two. Stale threads want sweeping by cleanup; a contextId the peer forgot months ago is dead weight.

This is where durability is a correctness requirement rather than an optimisation, and the contrast with MCP is the clearest way to see it: when milieu restarts, an MCP session is rebuilt unilaterally by re-handshaking, but a contextId cannot be — the peer is still holding the other half.

Not sessions. internal/service/session looks like the right home — it already constrains ids to contextId shape and models input-required — but its boundary is deliberate: a session is a conversation with the instance, written on the client’s behalf, living outside every agent’s writable home so that an agent cannot edit the history shaping its next prompt. An outbound thread inverts all three. The existing ruling points the same way: agents talking to each other is state, not a session.

One thing the one-shot backend has already surfaced: a threaded client has to decide what a peer’s refusal does to a thread. A 429 mid-conversation is not the same as one on the first message, and retrying into a contextId the peer may no longer hold is its own hazard. A one-shot call hands that judgement to its caller; a threaded one cannot.

Streaming stays deferred behind token streaming. Nothing about holding a thread requires it, llm.Backend.Generate returns a complete response anyway, and a run that does not outlive its request has nowhere to put partial results.

Token streaming

The A2A server ships (milieu a2a serve): JSON-RPC per agent, a public agent card, message/send, tasks/get, and message/stream over SSE. What streams today is the task: submitted, a working event per tool the agent consults, then a final event carrying the reply.

What does not stream is the prose. llm.Backend.Generate returns a complete response, so the reply arrives in one piece at the end of a run. Shipping token streaming means a streaming call in internal/platform/llm (Anthropic and OpenRouter both do SSE), a run loop that can emit partial assistant text while still assembling tool_use blocks, and the A2A layer forwarding those as artifact updates. The protocol surface is already in place for it.

Authoring the harness

There is an asymmetry in what milieu helps an operator write. The knowledge layer has an authoring assistant: internal/service/draft turns a source document into a knowledge article, with a system prompt whose load-bearing rule is that the model may not invent. The harness layer — skills, personality.md, responsibility.md, and the permission grants that bound a run — has nothing. Those are hand-authored from scratch, and they are the part that actually determines how an agent behaves.

Two pieces are worth building, in this order.

milieu permission suggest <agent>. The cheapest and least speculative. Grants today are guesswork: an operator writes an allowlist before seeing what the agent reaches for, then widens it when runs fail. But every run already records the tools it invoked and every denial it hit, in the audit log and in the transcript. Proposing a grant set from observed use — and flagging grants nothing has exercised in a retention window — is least-privilege maintenance out of data already kept. It should propose a diff for an operator to apply, never edit agent.toml itself: a tool that widens its own permissions on evidence it generated is the wrong shape regardless of how good the suggestion is.

Skill drafting and revision. milieu skill draft from a task description (the draft service’s shape, pointed at a skill rather than an article), and the harder one — proposing a skill revision from transcripts of runs that went wrong. Both were waiting on evaluation, which has since shipped: rewriting a prompt with no way to score the result is churn that feels like progress, and a generated revision is exactly the change an operator has least intuition about. milieu eval is now the thing that would tell them, which is what makes this worth building — a proposed revision that raises a suite’s pass rate is an argument, and one that does not is a draft nobody has to read.

Git-backed knowledge versioning

Knowledge is plain markdown rebuilt explicitly with knowledge reindex. Planned:

  • knowledge log <topic> — history for a knowledge path.
  • knowledge pin / knowledge unpin — pin a session to a commit SHA so explanations can cite the exact source revision they used.
  • A post-commit reindex hook, so edits re-index without a manual reindex.

This turns the knowledge tree into a git repository; today it is a directory of markdown files.


Recently shipped

For historical context — these were once “planned” and are now complete; they are documented as current features elsewhere:

  • Evaluating a change to an agent: milieu eval, over cases under var/evals/<agent>/<skill>/<case>.md — frontmatter of expectations, body is the input — exiting non-zero when one fails, so a suite is a gate. Assertions favour behaviour over prose (tools, forbids, max-tools alongside contains/excludes/matches), every case implicitly requires the run to finish rather than be cut off at its cap, and an opt-in judge criterion has a model grade the substance — tallied apart from the pass rate and never folded in, with an unreadable verdict counted as a failure. --backend decides what is measured: echo for CI, a live model before a release. A graded run is isolated — nothing recalled into the prompt, no transcript written, no topic classified or minted — because a measurement that joined the agent’s history would be graded next time on its own previous answer; the audit row stays, since an eval spends real tokens. Cases live outside every agent’s writable home: an agent that can edit the tests it is measured by is not being measured.
  • Scheduled tasks: kind: Schedule in the manifest and milieu schedule add|ls|show|enable|disable|rm, fired from the daemon’s tick against a five-field cron expression (internal/platform/cron). Off unless MILIEU_SCHEDULER=true, so declaring, linting and applying schedules works on a host that will never run one. The firing writes its own audit row as the cause of the act row it produces; missed slots are caught up at most once; a scheduled run opens no session and is checked against no session quota, and its transcript is filed under the schedule’s name. The declaration (var/schedules/<name>.toml) and the clock (state/<name>.json) are separate files, so apply cannot reset a running schedule’s history and a firing cannot rewrite its declaration. Claims are taken under a flock; schedules still only fire where a daemon runs. milieu lint checks the expression, the agent, the skill and the project membership ahead of time — the failure this most needed, since a schedule fails at three in the morning into a log.
  • A bounded, declarable tool-use loop: max-iterations in skill frontmatter, clamped to the installation’s MILIEU_MAX_ITERATIONS ceiling (default 50; a skill that declares nothing still gets 10). A run cut short — by the cap, by max_tokens, or by a tool-use turn that named no tool — returns its partial reply and says so: on skillrun.Result, in the audit row’s stop, in the transcript frontmatter, and on the session turn, so a REST client reads stop in the body and an A2A peer reads milieu/stop in the task metadata (live, streamed, and on a later tasks/get). Before this, truncation reached only stderr, and every network caller read a fragment as a finished answer.
  • Multi-agent collaboration: wake, ask, route, and the milieu daemon loop.
  • Projects (scoped, membership-gated knowledge and memory).
  • Declarative provisioning with milieu apply.
  • The REST API control plane and the trusted-zone gateway broker.
  • Per-agent and project identity at the API/gateway boundaries (CA issuance).
  • Multimodal attachments (images, PDFs) through messages, act, and the API.
  • Agent identity briefs: personality.md (voice) and responsibility.md (mandate) are prepended to the system prompt at the top of every run.
  • Skill provenance and rollback: every run records the skill’s declared version and a digest of what actually ran (audit, transcript, session), every build is snapshotted on load under var/skills/, and skill versions|diff|rollback plus milieu apply --prune manage them. Traffic-splitting is deliberately absent: a canary is a second agent, chosen at the session boundary.
  • A2A one-shot consumption: the gateway’s a2a backend brokers message/send to a remote peer, with input-required returned as a 409 that keeps the question and the contextId, a peer’s rate limit relayed as 429, and the peer’s agent card fetchable on the route. Streaming is deliberately not served.
  • Session quotas: milieu api quota set|ls and a sessionQuota manifest field — a concurrent cap and a token-bucket arrival rate per principal, checked when a conversation is opened over REST or A2A, refusing with 429 / -32005. Zero is unlimited on each knob, so an installation without one is unchanged.
  • A2A: milieu a2a serve — an inbound Agent2Agent endpoint per agent, on its own listener so a peer’s credential never reaches the control plane. A public agent card, message/send, tasks/get, and message/stream over SSE with a progress event per tool the agent consults; contextId is the session id and a task is one exchange within it.
  • Sessions: milieu session open|ls|show|close|rm, act --session <id>, and /v1/sessions with {"session": …} on the run endpoints, plus milieu compact to fold a long conversation’s older turns into a summary, and a built-in session tool that marks a reply as a question (input-required) rather than an answer — the durable record of one external conversation under var/sessions/<id>/, outside every agent’s writable home, replayed into later runs so a client continues rather than restarts. Kept indefinitely; swept only by an explicit cleanup session --retention.
  • Run transcripts: every run is recorded under var/agents/<code>/state/<topic>/, filed by topic (explicit, project, or LLM-classified), with prior same-topic history recalled into the prompt and on demand via the transcript tool. The body is a lossless record — tool calls and results in fenced blocks with ids, written in full — and parses back into the conversation it came from.
  • milieu cleanup: reclaims derived data — GCs orphaned attachment blobs (reachability + grace period) and applies retention to transcripts and settled messages; runs on the daemon’s hourly tick. Never touches memory or knowledge.
  • Built-in gateway tool: an in-process tool that calls the trusted-zone broker host-side with the agent’s proxy identity — structured request (method/path/query/headers/body) plus multipart/form-data attachment forwarding by blob digest — so agents reach brokered routes without shelling out to curl and never hold the proxy token.