User Guide
How to work with agents day to day: create them, give them skills, feed them
knowledge and memory, and have them collaborate. Every section is a short,
runnable example. Commands assume you have built the binaries and set
MILIEU_HOME — see GETTING-STARTED first.
For running servers, permissions internals, deployment, and the audit log, see OPERATIONS. For every flag and field, see REFERENCE.
Examples use
./milieu(running from the build directory). If you’ve installed milieu on yourPATH, drop the./.
Contents
Agents
An agent is an identity with a role, a set of skills, and its own knowledge and memory. Create a few:
./milieu agent create mira # default role: associate
./milieu agent create arun --role administrator
./milieu agent create vera --role auditor
./milieu agent lsCODE ROLE
arun administrator
mira associate
vera auditorRoles: administrator dispatches work, associate (default) does the work, assistant is a narrower associate (convention), auditor is read-only and cannot send messages — use auditors for review through the audit log.
Each agent lives at var/agents/<code>/ with an agent.toml (identity, role,
permissions, network), a skills/ directory, and its own knowledge/ and
memory/ layers.
Personality and responsibility briefs
Every agent ships two Markdown briefs that are prepended to its system prompt on every run, above project instructions and the skill body:
personality.md— how the agent communicates: tone, voice, demeanor.responsibility.md— what the agent is responsible for: scope, boundaries, and what “done” looks like.
These are different from role. Role (administrator / associate /
auditor / …) is an enforced capability category — it decides what an agent
may do (an auditor literally cannot send messages). The responsibility brief
is free prose that describes the job within that capability. Keep them
consistent: an auditor’s brief shouldn’t claim it resolves tickets.
agent create writes starter stubs you then edit. A good pair is short and
specific:
# personality.md
You are warm, fast, and reassuring. One or two short sentences per turn; ask one
question at a time. Never promise an outcome you don't control.# responsibility.md
You own first contact. You classify and route — you do not resolve.
## Boundaries
Do not issue refunds or quote policy you're unsure of — hand those to support.
## Definition of done
The customer is acknowledged, a ticket exists, and the conversation is handed to
the agent who owns the resolution.To manage briefs declaratively, point a milieu apply manifest at the files (they
are copied into the agent dir on apply):
kind: Agent
metadata: { name: triage }
spec:
role: associate
personality: ./agents/triage/personality.md
responsibility: ./agents/triage/responsibility.mdSee examples/contact-center for a three-agent
team — triage, support, supervisor — with a full set of contrasting briefs.
Skills
A skill is a capability in Anthropic’s Agent Skills format. The body is the system prompt. Two shapes:
- Flat file —
skills/<name>.md(quick). - Directory —
skills/<name>/SKILL.md, optionally bundling resources.
Authoring a skill
mkdir -p .milieu/var/agents/mira/skills
cat > .milieu/var/agents/mira/skills/summarise.md <<'EOF'
---
name: summarise
description: Summarise input in one short sentence
model: claude-haiku-4-5
---
You are a terse summariser. Reply with ONE short sentence.
EOF
./milieu skill --as mira lsFrontmatter is YAML: name and description are required; model,
allowed-tools, version, license, and metadata are optional. Run a skill
with input on stdin:
echo "The quarterly report shows revenue up 12% and churn down 3%." \
| ./milieu act --as mira summariseAfter authoring, validate it:
./milieu lint miraGiving a skill tools
Declare allowed-tools; Milieu exposes them (intersected with the agent’s
granted permissions) to the model as callable tools. Here’s a skill that answers
from the knowledge base:
./milieu permission grant mira 'knowledge(search:*)'
./milieu permission grant mira 'knowledge(get:*)'
cat > .milieu/var/agents/mira/skills/runbook-lookup.md <<'EOF'
---
name: runbook-lookup
description: Answer ops questions from the knowledge base
allowed-tools:
- knowledge(search:*)
- knowledge(get:*)
---
Search the knowledge base for the user's question (args=["search","<keyword>"]).
On a hit, read the file with args=["get","<path>"]. Answer in one short sentence,
quoting the source. If nothing matches, say "Not in the runbook."
EOF
echo "When do database backups happen?" | ./milieu act --as mira runbook-lookupThe built-in knowledge and skill tools are read-only and need no subprocess;
memory can write, so it is permission-gated. All three are audited as
sandbox: "builtin".
Letting the model pick the skill
act --auto lets the model choose from the catalog. The selection sees only each
skill’s name and description, so write good descriptions:
echo "Checkout page returns 500 for all users" | ./milieu act --as mira --auto
# stderr: auto-selected skill "triage"Bundled resources (progressive disclosure)
A directory skill can ship files the model loads only when needed:
mkdir -p .milieu/var/agents/mira/skills/triage/references
cat > .milieu/var/agents/mira/skills/triage/SKILL.md <<'EOF'
---
name: triage
description: Triage incoming tickets by severity
---
Classify the ticket as P1/P2/P3. Consult references/severity.md before deciding.
EOF
cat > .milieu/var/agents/mira/skills/triage/references/severity.md <<'EOF'
P1 = customer-facing outage. P2 = degraded service. P3 = cosmetic.
EOF
echo "Checkout page returns 500 for all users" | ./milieu act --as mira triageThe model fetches references/severity.md on demand via the built-in skill
tool (read-only, confined to the skill’s own directory, no grant needed).
Knowledge
Knowledge is hand-authored markdown (optionally with OKF frontmatter, below) in three layers — global (shared), agent (own), project (membership-gated) — that read as a union, agent > project > global, shadowing on a path clash.
Seed the global layer and index it:
mkdir -p .milieu/var/global/knowledge/articles
cat > .milieu/var/global/knowledge/articles/runbook.md <<'EOF'
# Deploy runbook
To deploy production: run `make release` from the main branch.
Database backups happen nightly at 02:00 UTC.
EOF
./milieu knowledge reindex # rebuilds the global layer's indexBrowse and search (add --as/--in to widen the union):
./milieu knowledge ls # global only
./milieu knowledge --as mira ls # agent + global
./milieu knowledge get articles/runbook.md # most-specific wins
./milieu knowledge search "database backups" # hybrid (default)Metadata (OKF frontmatter)
A knowledge file may carry YAML frontmatter in
OKF
form — the Open Knowledge Format: markdown plus a small metadata vocabulary. It
is optional throughout. A file without frontmatter counts as stable,
unverified and never stale, and its type defaults to its layer directory, so
every pre-existing base keeps working untouched.
---
type: news # routing/filtering; free-form string
title: "Ongoing: East region outage"
description: Fibre cut in the East region; restoration expected 18:00.
tags: [outage, credit]
status: stable # draft | stable | deprecated (default stable)
stale_after: 2026-06-15T18:00:00Z # the item is stale once now >= this
generated: { by: "process:noc-feed", at: 2026-06-15T06:05:00Z }
verified:
- { by: "human:ops-lead", at: 2026-06-15T07:00:00Z }
---
# Ongoing: East region outage
...title wins over the first H1. Keys milieu does not model are preserved, not
rejected. Trust tiers are derived from verified, never authored: no entry
is unverified, a non-human verifier is machine-confirmed, and any
human:<id> verifier makes it human-reviewed.
Metadata is indexed, so searches can be narrowed — and this is what stops a finished outage notice from outranking live policy forever:
./milieu knowledge search --fresh "outage credit" # drop items past stale_after
./milieu knowledge search --type policy "credit" # one concept type
./milieu knowledge search --tag router "DSL light" # one tag
./milieu knowledge search --status deprecated "credit" # what was superseded
./milieu knowledge search --current "credit" # still in force: not stale,
# deprecated, or superseded
./milieu knowledge search --verified "credit" # human-reviewed onlyStale or non-stable items are labelled rather than hidden in ls, search
and get — including in the knowledge tool’s output, so an agent sees the
caveat before it answers from the document:
news/outage-east-region.md [stale since 2026-06-15T18:00:00Z] # Ongoing: East region [outage]Two filenames are reserved: index.md describes its directory (its
description heads that group in ls) and log.md holds a dated history.
Neither is a concept. The bundle root’s index.md may declare okf_version.
Supersession
A concept may retire others by listing their concept ids — a path with the
.md dropped — under supersedes:
---
type: policy
title: Refund policy
supersedes: [articles/refund-policy-2024]
---This is milieu’s own key, not OKF’s: the spec conveys a relationship in the prose around a link, which cannot express a deterministic override. (Unknown keys are explicitly allowed, so the file stays conformant.)
It resolves across the layers being read, which is what makes it more than a
tidier status: deprecated: an ACME project concept can retire a global policy
it does not shadow by path, and the same global policy stays current for
everyone outside that project. Retired items are labelled — never hidden —
wherever they appear, and dropped by --current:
./milieu knowledge search --current "service credit" # live, not stale, not superseded
./milieu knowledge get articles/refund-policy-2024.md # prints "! superseded by articles/refund-policy"Layer shadowing still applies independently: a more-specific layer’s file wins
on a path clash, while supersedes retires a concept under a different
path.
Drafting an article from a document
knowledge draft turns a PDF, markdown or text file into an article with a
model:
./milieu knowledge draft "Q3 Refund Policy.pdf" # -> articles/q3-refund-policy.md
./milieu knowledge draft --path articles/refunds.md scan_001.pdf
./milieu knowledge --scope global draft --type axioms house-style.mdTwo rules shape it, and both come from what a knowledge path is.
The path is the concept’s identity — the id supersedes: points at — so it
is derived from the filename, not from what the model would have called the
article. A model-chosen name is different every run; a concept whose id moves
breaks every reference to it. The model’s title goes in the frontmatter, where
it can be rewritten freely. A filename that yields no usable path (日本語.pdf,
....pdf) is an error asking for --path, because the obvious fallback —
articles/untitled-1.md — would be both meaningless and permanent.
Every drafted article is status: draft, and nothing promotes it. Agents
read this base as fact. A model restructuring a document is a useful first pass
and no kind of authority, so reading the article against its source is a
person’s job — resource: records which document it came from, and
generated: which backend wrote it. Promotion to stable is a human edit.
Re-drafting the same source updates the article in place, which is what makes
“fix the source and run it again” work. Drafting over an item that is no longer
a draft is refused unless --force, so a reviewed article is never discarded
quietly.
This phase reads .pdf, .md and .txt. PDFs reach the model as documents;
anything else — .docx above all — needs converting first, and the command
says so rather than sending bytes no model will read.
Linting a knowledge base
./milieu knowledge lint # one layer (--scope/--as/--in, like reindex)
./milieu lint # every agent, project, and knowledge layerConformance findings: unparseable frontmatter (error — the body is still served),
a concept with no declared type, an unknown status, a stale item, a
supersedes target that exists nowhere, self-supersession, and supersession
cycles (error — no concept in a cycle can resolve as current).
Contradiction findings compare live concepts only — not superseded, not deprecated, not stale — since retiring one is exactly how an author settles a disagreement:
- two live concepts declaring the same
resource, which OKF defines as uniquely identifying one asset, so a second live definition conflicts by construction; - two live concepts of the same
typesharing a tag whose bodies state different magnitudes for the same kind of quantity (durations normalised across units, and percentages):
[warn] articles/refund-policy-2024.md: possible contradiction with articles/refund-policy.md:
both are live policy tagged "credit", but this says "8 consecutive hours"
and that says "4 consecutive hours" — supersede one, or narrow the tagsThat second check is a heuristic and says “possible”: prose is not typed, so it points at a pair worth a human’s eye rather than proving a conflict. It is kept narrow — a shared type and tag, the same unit family, no value in common — on the grounds that a lint which cries wolf is one nobody reads.
Each layer is linted on its own. Across layers a disagreement is usually the
design (ACME’s SLA is meant to override the general policy), so only within a
single layer is it a defect. Cross-layer supersedes targets are recognised as
such, not mistaken for typos.
Search modes
./milieu knowledge search --lex "backups" # FTS5 only (exact terms)
./milieu knowledge search --sem "database snapshots" # vectors only (meaning)
./milieu knowledge search --hybrid "incident comms" # both, fused (default)--lex matches terms; --sem matches meaning; --hybrid combines them. With no
embedder configured, all modes fall back to lexical — still useful. To enable
semantic search, see OPERATIONS → Semantic search.
reindex rebuilds one layer (the most-specific implied by the flags, or
--scope agent|project|global). The output tells you what’s active:
indexed N item(s) in the <layer> layer (lexical), or (lexical + semantic) once
an embedder is configured.
Memory
Memory is tool-written notes in the Claude memory format, layered exactly like
knowledge with an auto-maintained MEMORY.md index per layer.
# agent layer (default write target)
echo "The user prefers terse, technical answers." \
| ./milieu memory --as mira save user-style --description "tone preference" --type feedback
./milieu memory --as mira ls # agent + global union, labeled by layer
./milieu memory --as mira get user-style
./milieu memory --as mira search terseA memory file looks like:
---
name: user-style
description: tone preference
metadata:
node_type: memory
type: feedback
---
The user prefers terse, technical answers.type is advisory; conventions are user, feedback, project, reference.
Writes target one layer by context: --in <project> → project, --global →
global, otherwise the agent’s own.
Memory feeds skill runs two ways: passively, the union MEMORY.md index is
prepended to every skill’s system prompt; and actively, a permission-gated
memory tool lets a skill recall and record mid-run:
./milieu permission grant mira 'memory(search:*)'
./milieu permission grant mira 'memory(get:*)' # search returns a snippet; get reads the whole memory
./milieu permission grant mira 'memory(save:*)'Which version of a skill answered?
A skill’s version in frontmatter is a claim its author can forget to bump. So
every run also records a digest — the sha256 of the skill as loaded, covering
the manifest and every bundled resource, since progressive disclosure means a
reference file shapes a run as surely as the body does.
$ ./milieu skill --as mira ls
respond@1.0.0 sha256:2f2360278e494293 Answer a customer conversation…Both travel with the run, in the audit log and in the transcript’s frontmatter:
{"agent":"mira","cmd":"act","args":["respond"],
"skill_version":"1.0.0","skill_digest":"sha256:2f2360278e494293"}Edit the skill without touching version and the difference is visible where it
matters:
['respond'] 1.0.0 sha256:2f2360278e494293
['respond'] 1.0.0 sha256:21ba7b9e17c0043aA session remembers what it started on. The first turn records the skill, version and digest; a later turn that runs a different build is recorded on that turn and warned about, rather than blocked:
$ echo "and how long will it take?" | ./milieu act --as mira --session $S respond
warning: session 20260823t094126z-c33917 started on a different respond — this turn ran 1.0.0 (sha256:21ba7b9e17c0043a)
$ ./milieu session show $S
skill: respond 1.0.0 (sha256:2f2360278e494293)
…
--- 4 mira (2026-08-23 09:41:44) [skill sha256:21ba7b9e17c0043a]This is not a pin. Nothing keeps the old build around to run instead, so refusing would strand the conversation; what you get is that a conversation which changed character has the reason recorded in its own file. Keeping old builds — and rolling back to one — is not yet built.
Builds, diff and rollback
Every build that runs is recorded under var/skills/<agent>/<name>/<digest>/ —
outside the agent’s writable home, for the same reason sessions are. Recording
happens on load rather than on an install command, because skills arrive by
whatever route an operator uses and history that depends on remembering to
record it has holes.
$ ./milieu skill --as mira versions respond
sha256:2f2360278e494293 1.0.0 2026-08-23 10:50
sha256:21ba7b9e17c0043a 1.0.0 2026-08-23 10:50
* sha256:70652e302321dad7 1.1.0 2026-08-23 10:50
* = live nowTwo builds sit under 1.0.0 there: someone edited the skill without bumping it,
which is exactly what the digest is for.
$ ./milieu skill --as mira diff respond # previous vs live
~~~ SKILL.md
---
name: respond
- version: 1.0.0
+ version: 1.1.0
…
$ ./milieu skill --as mira rollback respond # back one build
$ ./milieu skill --as mira rollback respond 2f2360 # or to a named oneA selector is a digest (or a unique prefix, with or without the sha256:
label), a declared version, previous, or empty for the newest. The whole
sha256 is what gets stored — in the audit log, the transcript and the session,
where it is evidence — and listings abbreviate it to the first 16 hex
characters, which is also what you paste back as a selector. Rollback is
reversible: the build being replaced was itself recorded when it ran, so you
can roll forward again by digest. It also restores the form a skill had —
rolling a directory-form build back over a flat one leaves exactly the recorded
shape, not a mixture that would load the wrong thing.
Declaring skills, and pruning
milieu apply installs an agent’s skills from the manifest:
kind: Agent
metadata: { name: mira }
spec:
role: associate
skills:
- ./agents/mira/skills/respond # directory form
- ./agents/mira/skills/handover.md # flat formApply stays upsert by default, so a skill dropped from the manifest is left
alone. --prune removes the ones that are installed but no longer declared —
only for agents whose skills: the manifest actually declares. An agent with
no skills: key is not saying “no skills”, it is saying nothing, and every
manifest written before skills were declarable says nothing; skills: [] is how
you say none, and it does prune.
./milieu apply -f milieu.yaml --prune --dry-run # what would go
./milieu apply -f milieu.yaml --pruneRecorded builds are kept indefinitely, like sessions — they are what a rollback restores. Trim them when you want to:
./milieu cleanup builds --keep-builds 10 --dry-run
./milieu cleanup builds --keep-builds 10Whichever build is live is always kept, however old: it is what a rollback after a bad edit would restore.
Prune only ever touches skills. Agents and projects own memory, knowledge
and sessions; deleting those as a side effect of a manifest edit is not a call a
tool should make. A pruned skill is recoverable — every build that ran is
recorded, so milieu skill rollback <name> restores it.
Sessions
A session is the durable record of one external conversation: the turns a client sent, the replies it received, and enough identity to pick the conversation up again later.
S=$(./milieu session --as mira open --topic billing-disputes) # prints the id
echo "Hi, this is Priya, account 48213. My internet was out all morning." \
| ./milieu act --as mira --session "$S" respond
echo "So how much will I actually get back?" \
| ./milieu act --as mira --session "$S" respond # continues, no context repeated
./milieu session show "$S"
./milieu session close "$S" # refuses further turns; still readableThe second run knows who Priya is because the session replayed the first
exchange into it. Without --session, each run still starts cold and continuity
is only the recall index of recent same-topic transcripts.
Over the REST API
The same conversation from a client outside milieu — the session is opened, then named on each run:
SID=$(curl -sk -X POST "$AUTH" -d '{"topic":"billing-disputes"}' \
https://localhost:8089/v1/sessions | jq -r .id)
curl -sk -X POST "$AUTH" https://localhost:8089/v1/skills/respond/run \
-d "{\"input\":\"Hi, this is Priya, account 48213…\",\"session\":\"$SID\"}"
curl -sk -X POST "$AUTH" https://localhost:8089/v1/skills/respond/run \
-d "{\"input\":\"And what account did I say that was?\",\"session\":\"$SID\"}"
curl -sk "$AUTH" https://localhost:8089/v1/sessions/$SID # metadata + turns
curl -sk -X POST "$AUTH" https://localhost:8089/v1/sessions/$SID/close| Endpoint | Does |
|---|---|
POST /v1/sessions | open one (id, project, topic, title all optional) → 201 |
GET /v1/sessions | the caller’s own, newest first, without turns |
GET /v1/sessions/{id} | one session with its turns |
POST /v1/sessions/{id}/close | refuse further turns; still readable |
DELETE /v1/sessions/{id} | remove it and its turns → 204 |
POST /v1/skills/{name}/run · /v1/skills/run | {"session": "<id>"} continues one |
A caller only ever sees its own sessions: another agent’s id returns 404, not
403, so probing reveals nothing. Resuming a closed session is 409, refused
before the model is called rather than after. ?in=<project> on a run must match
the project the session was opened with.
When the agent needs something back
A run is synchronous, so every reply looks finished. It often is not: an agent
that must ask for an order number before it can act has produced a question, not
an answer. The built-in session tool is how a skill says so:
5. If you cannot finish without something only the customer can give you —
an order number, a date, a choice between options — say so first
(session, args=["request-input", "<what you need>"]) and then ask for it.$ echo "Hi, I want a refund for my broken order." | ./milieu act --as mira --session "$S" respond
session 20260823t075101z-df7da8 is waiting on the client for order number and details about what was broken
I'd be happy to help you with that! To look into your refund, I need …
$ ./milieu session --as mira ls
20260823t075101z-df7da8 mira input-required 2026-08-23 07:51 Hi, I want a refund…That marks the agent’s turn input-required — A2A’s state for a task waiting on
its client — so an outstanding conversation is visible as outstanding instead of
sitting in a queue looking answered. Over the API the run response carries
"state": "input-required" and "awaiting", and the session’s own state shows
the same.
The state is derived, not stored: a session is closed when the client ends
it, input-required while the newest turn is an agent turn marked waiting, and
open otherwise. So the client’s next message clears it — nothing to reset by
hand, and no flag that can drift from the turns. Only an agent turn can leave a
conversation waiting.
When that next run starts, it is told what it asked for:
On your last turn you asked them for the order number. Read their message as that answer where it fits, and say so if it does not.
The tool is permission-gated like any other (session(request-input:*)), and it
never writes the session itself — it records the declaration on the run, and the
boundary writes the turn. An agent still cannot author its own record.
Compaction
A conversation outlives a context window. Left alone, a resumed run replays the
newest turns that fit the budget and tells the model the rest was elided — the
older turns are still on disk, but the agent no longer knows what they said.
milieu compact folds them into a summary instead, so their substance is
carried rather than dropped:
./milieu compact --dry-run # what would be compacted, no model calls
./milieu compact # every session over the threshold
./milieu compact --keep 4 --over 0 <id> # one session, regardless of size20260823t074055z-3d4d12: summarised 8 turn(s), replay 2949 -> 1736 bytesThe newest --keep turns (default 8) stay verbatim; everything older is folded.
--over (default 24576, the history budget) skips sessions small enough not to
need it — pass --over 0 to compact anyway. Re-running folds only the turns
added since the last pass, handing the previous summary back to the model rather
than re-reading old turns, so it is cheap to schedule:
0 * * * * milieu compact --over 32768It is non-destructive. The summary is a derived summary.md beside the
turns; no turn is ever rewritten or removed, and session show keeps printing
the whole conversation. Compaction changes what a run replays, not what
happened.
It is explicit. Each session compacted costs a model call, so nothing
compacts on its own — the same reason cleanup is a command rather than a
background sweep. Every pass writes an audit row (cmd: "compact") with the
model and tokens it spent, billed to the agent whose conversation it is.
At the run, the summary is framed in the system prompt as an account of earlier turns — not faked as something the client said — so the message list stays a real exchange. A summarised turn is not reported as elided: that is the difference compaction makes.
Session, or transcript?
They are deliberately different things, and the difference is a trust boundary:
| Session | Transcript | |
|---|---|---|
| Records | what the client said and heard | how the agent produced it — system prompt, tool calls, delegation |
| Lives in | var/sessions/<id>/ | var/agents/<code>/state/<topic>/ |
| Writable by the agent | no (outside its sandbox home) | yes |
| Replayed into later runs | yes | no — recalled by index, read on demand |
| Retention | kept indefinitely | swept by cleanup transcript |
That an agent cannot write the session is what makes resuming from it safe: an
agent that could edit its own history could rewrite its next prompt. Agents
talking to each other is state, not a session — those exchanges stay in
transcripts and messages, bounded by reply_depth. Each run’s transcript names
its session, so session → run → audit joins up.
On disk
var/sessions/<id>/index.md type: session, agent, project, topic, status, timestamps
var/sessions/<id>/0001-user.md one file per turn, append-only
var/sessions/<id>/0002-agent.mdTurn files are never rewritten and claim their sequence by exclusive create, so the API server, the daemon and a CLI invocation can append concurrently without losing each other’s turn. Turn bodies use the transcript body format, so an attachment is recorded by digest rather than inlined.
Identity, scope and lifetime
- Id — minted as a sortable stamp (
20260823t071416z-ec2da7), or supplied with--idwhen it matches[a-z0-9][a-z0-9-]{7,63}, the shape an A2AcontextIdtakes, so a client’s own id can be adopted verbatim. - One agent per session. A client talking to three agents opens three sessions; delegation between them is internal state.
- The session pins the topic, so every run in one conversation files together and clients never need to know topics exist.
- Kept indefinitely. Sweep idle ones explicitly:
./milieu cleanup session --retention 720h(barecleanupnever touches them).
How much is replayed
A conversation outlives a context window, so a resumed run replays the most recent turns that fit a byte budget (24 KB by default), always starting at a client turn. When older turns are dropped the model is told its view is a suffix, rather than left to invent the beginning. Compaction — summarising the elided part instead of dropping it — is not yet built.
Transcripts
Every run is recorded as a transcript — the system prompt and each conversation
turn — under var/agents/<code>/state/<topic>/<timestamp>-<id>.md. Transcripts are
the agent’s own working memory: related conversations cluster under a topic, and
prior same-topic history is recalled into later runs. (This is distinct from the
hash-chained audit log, which records invocation metadata; transcripts live in the
agent’s own dir and are not tamper-evident.)
What a transcript file holds
Frontmatter (type: transcript, topic, agent, skill, model, timings, token
usage) then the conversation: a ## System section and one ## Turn <n> — <role> section per message. Tool traffic and attachments are written as fenced
blocks with a JSON meta line:
## Turn 2 — assistant
Let me check the policy.
````milieu:tool-call {"id":"toolu_01","name":"knowledge"}
{
"args": ["search", "outage credit"]
}
````
## Turn 3 — user
````milieu:tool-result {"error":false,"id":"toolu_01","name":"knowledge"}
articles/refund-policy.md # Refund policy
````The id pairs a result with the call it answers, so a turn that fires three
tools stays unambiguous. Arguments and results are recorded in full — exactly
what the model was shown, with nothing truncated on the way to disk — because
the arguments are half of what an auditor needs to judge a call.
The format round-trips: it parses back to the same conversation it was rendered from, so the file is the record rather than a rendering of one. Two consequences worth knowing:
- The fence grows longer than any backtick run inside the content, and headings inside a fenced block are not read as structure — a tool result full of markdown cannot corrupt the file.
- Attachment payloads are the one exception. An image or PDF is recorded by
sha256digest, media type and size, not inlined: the bytes already live in the blob store under that digest.
A body written before this format (or hand-edited) still reads — its content comes back as prose rather than structure, never dropped.
The topic is chosen by a precedence ladder, most specific first:
- Explicit —
act --topic <topic>(or thetopicfield on the REST run API). - Project — a project-scoped run (
--in <project>) files under the project. - Classified — otherwise the model files the conversation under an existing topic, or invents a new kebab-case one when none fit.
miscellaneous— the fallback when nothing else applies.
echo "I need a refund for order 48213" | ./milieu act --as mira --topic refunds triageLike memory, transcripts feed runs two ways: passively, an index of the topic’s
recent transcripts is prepended to the system prompt; and actively, a
permission-gated transcript tool lets a skill recall prior runs mid-conversation:
./milieu permission grant mira 'transcript(:*)'
# the agent can then call: transcript topics | ls <topic> | get <topic>/<id> | search <query>A browsable state/TOPICS.md lists every topic with its transcript count and the
most recent title.
Transcripts (and orphaned attachment blobs) are reclaimed by milieu cleanup —
on demand, or automatically on the daemon’s hourly tick:
./milieu cleanup --dry-run # preview across all targets
./milieu cleanup transcript --keep-per-topic 20 # keep the 20 newest per topic
./milieu cleanup --retention 720h # drop transcripts/messages older than 30dcleanup only reclaims derived data (blobs, transcripts, settled messages); it
never deletes memory or knowledge.
Messaging between agents
Agents collaborate by message. The three verbs:
# arun sends mira a task
MILIEU_AGENT=arun ./milieu message send mira "status" <<< "How is the deploy going?"
# deliver outbound -> inbound (one-shot router)
./milieu route
# mira reads
MILIEU_AGENT=mira ./milieu message inbox
MILIEU_AGENT=mira ./milieu message read 1Having an agent answer automatically
wake drains the caller’s inbox, runs a skill (default respond) on each unread
message, and sends the reply back:
cat > .milieu/var/agents/mira/skills/respond.md <<'EOF'
---
name: respond
description: Reply briefly to incoming messages
---
You will receive a message header followed by a body. Reply in one short sentence.
EOF
MILIEU_AGENT=arun ./milieu message send mira "status" <<< "How is the deploy going?"
./milieu route
./milieu wake --as mira # mira's respond skill answers
./milieu route
MILIEU_AGENT=arun ./milieu message inbox # arun sees the reply--auto dispatches each message to the model-selected skill (with --skill as
the fallback). To remove the manual route/wake entirely, run the daemon —
see OPERATIONS → The daemon.
Synchronous request/reply
ask sends and blocks until the reply arrives, printing just the body. It needs
milieu daemon running:
echo "What's the deploy command?" | ./milieu ask --as arun --to mira
echo "summarise last week" | ./milieu ask --as arun --to mira --subject summary-q --timeout 30sExit codes: 0 reply received, 1 send/setup error, 124 timeout.
Projects
A project is a named workspace that bundles instructions (prepended to skill prompts for in-project runs), a member roster (gates who can act and who can reach the project’s knowledge/memory), and a project layer of knowledge and memory between the agent and global layers.
./milieu project create acme --name "ACME Initiative" --instructions instructions.md
./milieu project member add acme mira
./milieu project ls
./milieu project show acmeSeed project knowledge and index that layer:
mkdir -p .milieu/var/projects/acme/knowledge/articles
cat > .milieu/var/projects/acme/knowledge/articles/launch.md <<'EOF'
# Launch date
ACME ships 2026-06-15.
EOF
./milieu knowledge --as mira --in acme reindex --scope project
./milieu knowledge --as mira --in acme search launch # sees agent + project + globalAct in the project — the member check applies, instructions are prepended, and the built-in tools resolve the full union:
echo "When does ACME launch?" | ./milieu act --as mira --in acme runbook-lookupA non-member gets agent "mira" is not a member of project "acme". Without
--in, act runs against the agent + global layers only.
Multimodal input (images & PDFs)
act and message send take a repeatable --attach <file> (png/jpeg/gif/webp or
PDF; anything else is rejected before any API call). Requires a vision-capable
model.
echo "what colour dominates this image?" | ./milieu act --as mira describe --attach shot.png
echo "what does the first line say?" | ./milieu act --as mira readpdf --attach doc.pdfAttachments on messages ride to the recipient: the bytes are stored once in the
content-addressed blob store and referenced by digest, so wake hands them to the
recipient’s skill as image/document blocks:
echo "what colour is this?" | ./milieu message send --as arun --attach shot.png mira "colour-check"
./milieu route && ./milieu wake --as mira && ./milieu routeWorked examples
The examples/ directory has four complete, runnable workspaces,
each provisioned with one milieu apply and driven as a Runme notebook. They
run offline (MILIEU_LLM=echo) or against a real model with ANTHROPIC_API_KEY:
| Example | Demonstrates |
|---|---|
| contact-center | Multi-role support desk; gateway-brokered CRM token; external chat widget over the API |
| legal-expert | Three-layer knowledge (global statutes + project clauses); read-only auditor reviewer |
| process-audit | SOC 2 readiness; tamper-evident audit log; read-only logs broker |
| data-analyst | Data-dictionary knowledge; gateway warehouse route; SQL named-queries |
Start with examples/contact-center for a tour of agents + project + gateway +
API together.