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.
- Bounding cost — arrival is bounded, consumption is not. A runaway agent inside its session quota is visible, not stopped.
- 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.
- Draining in-flight work on shutdown — a restart costs a caller the answer to a request the server accepted, indistinguishable from a crash.
- 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.
- Per-agent action signing — the keyed-chain half is much cheaper than the per-message signing half and closes the larger hole; worth splitting.
- 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.
- A task identity of its own — unblocks long-running work, and with it the synchronous half of approval.
- Consuming A2A: threaded calls — a correctness requirement, but only for installations holding multi-turn conversations with peers.
- Token streaming — presentation. The protocol surface is already in place for it.
Last.
- Authoring the harness — unblocked now that
evaluation exists to score a rewrite. The
permission suggesthalf is separable and cheap, and depends on none of it. - 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-onlyIt 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 atTimeoutStopSecregardless 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/cancelreturn-32002honestly 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
MaxReplyDepthand 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 undervar/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-toolsalongsidecontains/excludes/matches), every case implicitly requires the run to finish rather than be cut off at its cap, and an opt-injudgecriterion has a model grade the substance — tallied apart from the pass rate and never folded in, with an unreadable verdict counted as a failure.--backenddecides what is measured:echofor 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: Schedulein the manifest andmilieu schedule add|ls|show|enable|disable|rm, fired from the daemon’s tick against a five-field cron expression (internal/platform/cron). Off unlessMILIEU_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 theactrow 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, soapplycannot reset a running schedule’s history and a firing cannot rewrite its declaration. Claims are taken under aflock; schedules still only fire where a daemon runs.milieu lintchecks 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-iterationsin skill frontmatter, clamped to the installation’sMILIEU_MAX_ITERATIONSceiling (default 50; a skill that declares nothing still gets 10). A run cut short — by the cap, bymax_tokens, or by a tool-use turn that named no tool — returns its partial reply and says so: onskillrun.Result, in the audit row’sstop, in the transcript frontmatter, and on the session turn, so a REST client readsstopin the body and an A2A peer readsmilieu/stopin the task metadata (live, streamed, and on a latertasks/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 themilieu daemonloop. - 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) andresponsibility.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/, andskill versions|diff|rollbackplusmilieu apply --prunemanage them. Traffic-splitting is deliberately absent: a canary is a second agent, chosen at the session boundary. - A2A one-shot consumption: the gateway’s
a2abackend brokersmessage/sendto a remote peer, withinput-requiredreturned 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|lsand asessionQuotamanifest 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/sessionswith{"session": …}on the run endpoints, plusmilieu compactto fold a long conversation’s older turns into a summary, and a built-insessiontool that marks a reply as a question (input-required) rather than an answer — the durable record of one external conversation undervar/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 explicitcleanup 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 thetranscripttool. 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
gatewaytool: 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) plusmultipart/form-dataattachment forwarding by blob digest — so agents reach brokered routes without shelling out to curl and never hold the proxy token.