Skip to content
User guide

User guide

This guide shows you 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 that you’ve built the binaries and set MILIEU_HOME. If you haven’t, start with GETTING-STARTED.

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 your PATH, omit 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 ls
CODE  ROLE
arun  administrator
mira  associate
vera  auditor

Roles: administrator dispatches work, associate (default) does the work, assistant is a narrower associate (convention), and auditor is read-only and can’t send messages. Use auditors to review work 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 has two Markdown briefs, which Milieu prepends to its system prompt on every run, ahead of the charter, department context, project instructions, and the skill body:

  • personality.md: How the agent communicates: tone, voice, and demeanour.
  • responsibility.md: What the agent is responsible for: scope, boundaries, and what “done” looks like.
A run's system prompt, top to bottom: the personality and responsibility briefs, the charter, the index of recent transcripts on the topic, the memory index, department context outermost first, the project instructions, and the skill body.

These briefs are different from the role. A role (administrator, associate, auditor, and so on) is an enforced capability category that decides what an agent may do (an auditor can’t send messages at all). The responsibility brief is free prose that describes the job within that capability. Keep them consistent: an auditor’s brief shouldn’t claim that it resolves tickets.

agent create writes starter stubs that you then edit. Keep the pair 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 (apply copies them into the agent directory):

kind: Agent
metadata: { name: triage }
spec:
  role: associate
  personality: ./agents/triage/personality.md
  responsibility: ./agents/triage/responsibility.md

For a three-agent team (triage, support, and supervisor) with a full set of contrasting briefs, see examples/contact-center.

Skills

A skill is a capability in Anthropic’s Agent Skills format whose body is the system prompt. It has two shapes:

  • Flat file: skills/<name>.md (quick).
  • Directory: skills/<name>/SKILL.md, optionally with bundled 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 ls

Frontmatter is YAML. The name and description fields are required. The model, allowed-tools, version, license, and metadata fields are optional, as are Milieu’s own max-iterations, acts-on, consult (see Consulting another agent within a run), context, topic, and reasoning:

context:
  knowledge: 2        # search knowledge with the message, read the best 2 hits in full
  prefer: card.md     # read card.md from a hit's directory in its place, when there is one
topic: renewals       # file runs here when the caller names no topic: no classification call
reasoning: low        # minimal | low | medium | high | max; providers that cannot, ignore it
model-options:        # provider request keys, passed as given (model, messages, system,
  temperature: 0      # tools and stream are milieu's and refused)

The context setting does, before the first model call, the searching that a skill would otherwise spend two calls on. It reads through the agent’s own knowledge grant and is audited in the same way as tool calls. The documents land in the prompt with their paths, and the knowledge tool stays available for what they don’t cover. Every model call adds latency, so for a skill that must answer fast, these settings save the most time.

An agent’s manifest document can carry the same three model settings (model, reasoning, and modelOptions). They take precedence over the installation’s default, but not over anything a skill says. A skill’s own model and reasoning win, and its model-options merge over the agent’s key by key. A router is the usual case:

kind: Agent
metadata: { name: concierge }
spec:
  model: google/gemini-2.5-flash
  modelOptions:                       # Vertex AI: Gemini 2.5 with thinking off
    temperature: 0
    extra_body: { google: { thinking_config: { thinking_budget: 0 } } }

Run a skill with input on stdin:

echo "The quarterly report shows revenue up 12% and churn down 3%." \
  | ./milieu act --as mira summarise

After authoring, validate it:

./milieu lint mira

Giving a skill tools

Declare allowed-tools. Milieu exposes them (intersected with the agent’s granted permissions) to the model as callable tools, and holds every call the run makes to them. A tool the agent holds but the skill doesn’t declare is refused, whether the model names it or a code script calls it, and the refusal is in the audit log with exit -1. A declared rule counts only if the agent is granted that same rule: memory(ls) in a skill needs memory(ls) in the agent’s permissions, and memory(:*) alone isn’t enough. A skill that declares no allowed-tools runs with all of its agent’s grants. 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-lookup

The built-in knowledge and skill tools are read-only and need no subprocess. The memory tool can write, so it’s permission-gated. All three are audited as sandbox: "builtin".

Letting the model pick the skill

act --auto lets the model choose from the catalogue. The selection uses 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 triage

The model fetches references/severity.md on demand through 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 Open Knowledge Format (OKF) frontmatter, described in the following section). It lives in three layers, global (shared), agent (own), and project (membership-gated), which read as a union in the order agent > project > global, shadowing on a path clash.

Knowledge is read from the agent layer, then the project layer, then the global layer, as one union in which the most specific file wins 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 index

Browse 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: Markdown plus a small metadata vocabulary. It’s optional throughout. A file without frontmatter counts as stable, unverified, and never stale, and its type defaults to its layer directory, so every existing base keeps working unchanged.

---
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 that Milieu doesn’t model are preserved. Trust tiers are derived from verified and can’t be set directly: no entry is unverified, a non-human verifier is machine-confirmed, and any human:<id> verifier makes it human-reviewed.

Metadata is indexed, so you can narrow a search, for example to stop a finished outage notice from outranking current policy indefinitely:

./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 only

Stale or non-stable items remain visible and are labelled 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 can 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]
---

The key is Milieu’s own. OKF conveys a relationship in the prose around a link, which can’t express a deterministic override. (OKF explicitly allows unknown keys, so the file stays conformant.)

Unlike status: deprecated, supersedes resolves across the layers being read: an ACME project concept can retire a global policy that it doesn’t shadow by path, and the same global policy stays current for everyone outside that project. Retired items are labelled wherever they appear, and --current drops them:

./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, and supersedes retires a concept under a different path.

Drafting an article from a document

knowledge draft uses a model to turn a PDF, Markdown, or text file into an article:

./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.md

Two rules apply.

First, the path is the concept’s identity. It’s the ID that supersedes: points at, so it’s derived from the filename. The model doesn’t choose it: a model-chosen name is different every run, and 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.

Second, every drafted article is status: draft. Agents read this base as fact, and a model’s restructuring of a document is a first pass with no authority behind it, so a person reads the article against its source before promoting it. The resource: field records which document it came from, and generated: records which backend wrote it. Only a human edit promotes an article to stable.

Re-drafting the same source updates the article in place, so you can fix the source and run it again. Drafting over an item that’s no longer a draft is refused unless you pass --force, so you can’t discard a reviewed article by accident.

This phase reads .pdf, .md, and .txt. PDFs reach the model as documents. You must convert anything else (.docx in particular) first: the command says so and sends nothing to the model.

Linting a knowledge base

./milieu knowledge lint                    # one layer (--scope/--as/--in, like reindex)
./milieu lint                              # every agent, project, and knowledge layer

Conformance findings

Lint reports these conformance problems:

  • Unparseable frontmatter (an error, although 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.
  • Supersession cycles (an error, because no concept in a cycle can resolve as current).

Contradiction findings

Contradiction checks compare live concepts only (superseded, deprecated, and stale ones are left out), because an author settles a disagreement by retiring a concept. Lint reports:

  • 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 type sharing 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 tags

That second check is a heuristic and says “possible”: prose isn’t typed, so the finding points at a pair for a person to read and doesn’t prove a conflict. It’s kept narrow (a shared type and tag, the same unit family, no value in common) so that its warnings stay rare enough to be read.

Each layer is linted on its own. Across layers, a disagreement is usually intended (ACME’s service-level agreement (SLA) overrides the general policy), so only a disagreement within a single layer is a defect. A supersedes target in another layer is recognised and isn’t reported as missing.

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, and --hybrid combines them. With no embedder configured, all modes fall back to lexical, which is still useful. To enable semantic search, see OPERATIONS → Semantic search.

reindex rebuilds one layer (the most specific one 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) after an embedder is configured.

Memory

Memory is tool-written notes in the Claude memory format, layered in the same way as 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 terse

A memory file looks like:

---
name: user-style
description: tone preference
metadata:
  node_type: memory
  type: feedback
---

The user prefers terse, technical answers.

The type field is advisory. Conventional values are user, feedback, project, and reference. Writes target one layer by context: --in <project> → project, --global → global, and otherwise the agent’s own.

Memory feeds skill runs in two ways. Passively, the union MEMORY.md index is prepended to every skill’s system prompt. 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 only what its author declared, and the author can forget to bump it. Every run therefore also records a digest, the SHA-256 hash of the skill as loaded, covering the manifest and every bundled resource, because a reference file loaded by progressive disclosure shapes a run as much 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"}

If you edit the skill without touching version, the record shows the difference:

['respond'] 1.0.0 sha256:2f2360278e494293
['respond'] 1.0.0 sha256:21ba7b9e17c0043a

A session records what it started on. The first turn records the skill, version, and digest. A later turn that runs a different build is allowed, but Milieu records the build on that turn and prints a warning:

$ 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 isn’t a pin. Nothing keeps the old build around to run instead, so refusing the turn would strand the conversation. Instead, a conversation that changed character has the reason recorded in its own file. For keeping old builds and rolling back to one, see Builds, diff, and rollback.

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 that sessions are. Recording happens on load, because skills arrive by whatever route an operator uses, and a history that depended on an install command being run would have 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 now

In that list, two builds share 1.0.0: someone edited the skill without bumping the version, and the digest shows the difference.

$ ./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 one

A selector is a digest (or a unique prefix, with or without the sha256: label), a declared version, previous, or empty for the newest. Milieu stores the whole SHA-256 hash (in the audit log, the transcript, and the session, where it’s 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 that a skill had: rolling a directory-form build back over a flat one leaves the recorded shape, with nothing of the flat form left behind to load.

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 form

Apply 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 declares. An agent with no skills: key says nothing about skills, and that includes every manifest written before skills were declarable. By contrast, skills: [] declares no skills, and it does prune.

./milieu apply -f milieu.yaml --prune --dry-run   # what would go
./milieu apply -f milieu.yaml --prune

--prune also disables an agent or project that an earlier apply declared and this manifest no longer does. It deletes neither. A disabled agent keeps its memory, knowledge, sessions, keys, and skills, and does nothing: no run of it starts through any entry point, its keys are refused, nothing new is sent to it or consulted of it, and its schedules do nothing. A disabled project starts no run or session, and its knowledge and memory stay readable and take no writes, an operator’s included. Declaring either again enables it, as do milieu agent enable and milieu project enable, and disable switches one off by hand. Without --prune, the plan only names what it would disable, because a manifest may describe part of an installation. An agent made by hand, with milieu agent create, was never declared, so no manifest disables it.

milieu export leaves disabled agents and projects out, because declaring them would enable them. The admin portal marks them in its lists, says on each one’s page what being disabled means, counts disabled agents on the overview, leaves them out of the chat picker, and warns in a conversation whose agent or project is disabled. An admin disables or enables one from the Status section at the foot of its page. Each change is in the portal’s log, and a policy can narrow it like any other portal change (admin.agent.disable, admin.agent.enable, admin.project.disable, admin.project.enable), or have it wait for a second operator.

A skill edited in place, or added to an agent’s skills/ by hand, survives only until the next apply puts the declared ones back. milieu apply drift lists such edits, and any other change made outside apply, before that happens:

./milieu apply drift      # exits 2 if anything differs from what the last apply left

For what it compares, see Drift.

Recorded builds are kept indefinitely, like sessions, because a rollback restores from them. Trim them when you want to:

./milieu cleanup builds --keep-builds 10 --dry-run
./milieu cleanup builds --keep-builds 10

The live build is always kept, however old, because a rollback after a bad edit would restore it.

Prune only ever touches skills and eval suites, and evals: works the same way as skills:. Agents and projects own memory, knowledge, and sessions, and a manifest edit never deletes those. A pruned skill is recoverable: every build that ran is recorded, so milieu skill rollback <name> restores it. A pruned eval suite isn’t kept. Its only other copy is the source in your manifest directory.

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 readable

The second run knows who Priya is because the session replayed the first exchange into it. Without --session, each run still starts cold, and the only continuity is the recall index of recent same-topic transcripts.

A session takes one turn at a time, whoever sends it: the CLI, the REST API, MCP, A2A, or the portal. A turn sent while another is running in the same session is refused with a “busy” error (REST answers 409), so the record always alternates question and reply. Retry after the first turn ends. A background task holds its session from the moment it is accepted.

Over the REST API

For a client outside Milieu, the same conversation opens the session, and then names it 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
EndpointDoes
POST /v1/sessionsopen one (id, project, topic, title all optional) → 201
GET /v1/sessionsthe caller’s own, newest first, without turns
GET /v1/sessions/{id}one session with its turns
POST /v1/sessions/{id}/closerefuse further turns (it stays 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 returns 409 and is refused before the model is called. On a run, ?in=<project> must match the project that the session was opened with.

When the agent needs something back

A run is synchronous, so every reply looks finished, but an agent that must ask for an order number before it can act has produced a question and is still waiting. A skill says so with the built-in session tool:

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 (over A2A, a task in TASK_STATE_INPUT_REQUIRED, waiting on its client), so an outstanding conversation is visible as outstanding. Over the API, the run response carries "state": "input-required" and "awaiting", and the session’s own state shows the same.

When the answer is one of a few, the agent can offer them, so a client can show them as buttons:

   (session, args=["request-input", "the cost centre", "--option", "CC-104", "--option", "CC-220"])

The client may still answer in its own words. Over the API, the offered answers arrive in inputs, a typed list beside awaiting:

"inputs": [{ "kind": "question", "prompt": "the cost centre", "options": ["CC-104", "CC-220"] }]

A task parked on a person’s approval lists its approvals there too, as "kind": "approval" with the request, its queue, the policies that asked, and the decision so far. See Operations → Runs a client doesn’t wait on.

The state is derived from the turns: a session is closed when the client ends it, input-required while the newest turn is an agent turn marked waiting, and open otherwise. The client’s next message therefore clears it, with nothing to reset by hand and no stored flag that can drift from the turns. Only an agent turn can leave a conversation waiting.

Opening a session puts it in the open state. An agent turn that requests input moves it to input-required, and the client's next message moves it back to open. The client can close the session from either state.

The next run’s prompt states what the agent 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:*)). It doesn’t write the session itself: it records the declaration on the run, and the boundary writes the turn, so the agent still can’t 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 that 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, so their substance is carried into the run:

./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 size
20260823t074055z-3d4d12: summarised 8 turn(s), replay 2949 -> 1736 bytes

The newest --keep turns (default 8) stay verbatim, and everything older is folded. The --over flag (default 24576, the history budget) skips sessions small enough not to need it. To compact anyway, pass --over 0. Rerunning folds only the turns added since the last pass and hands the previous summary back to the model, so it’s cheap to schedule:

0 * * * *  milieu compact --over 32768

Compaction 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 only what a run replays.

Compaction is explicit. Each session compacted costs a model call, so nothing compacts on its own. For the same reason, cleanup is a command that you run. 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, never as a client message, so the message list stays a real exchange, and a summarised turn is no longer reported as elided.

Session, or transcript?

The difference between them is a trust boundary:

SessionTranscript
RecordsWhat the client said and heardHow the agent produced it: system prompt, tool calls, and delegation
Lives invar/sessions/<id>/var/agents/<code>/state/<topic>/
Writable by the agentNo (outside its sandbox home)Yes
Replayed into later runsYesNo (recalled by index, read on demand)
RetentionKept indefinitelySwept by cleanup transcript

Resuming from a session is safe because the agent can’t write it: an agent that could edit its own history could rewrite its next prompt. Conversations between agents are state, kept in transcripts and messages and bounded by reply_depth. No session is involved. 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.md

Turn 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.

Identity, scope, and lifetime

  • ID: Minted as a sortable stamp (20260823t071416z-ec2da7), or supplied with --id when it matches [a-z0-9][a-z0-9-]{7,63}, the shape that an A2A contextId takes, so a client’s own ID can be adopted verbatim.
  • One agent per session: A client talking to three agents opens three sessions, and delegation between them is internal state.
  • Topic: The session pins the topic, so every run in one conversation files together and clients never need to know that topics exist.
  • Retention: Sessions are kept indefinitely. Sweep idle ones explicitly with ./milieu cleanup session --retention 720h (bare cleanup never 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 that its view is a suffix, so it doesn’t invent the beginning. To summarise the elided part, use compaction.

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. Transcripts are distinct from the hash-chained audit log, which records invocation metadata. They live in the agent’s own directory and aren’t tamper-evident.

What a transcript file holds

A transcript file holds frontmatter (type: transcript, topic, agent, skill, model, timings, and token usage) and 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 (the same text 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 that it was rendered from, so the file itself is the record. Two consequences follow:

  • The fence grows longer than any backtick run inside the content, and headings inside a fenced block aren’t read as structure, so a tool result full of Markdown can’t corrupt the file.
  • Attachment payloads are the one exception: an image or PDF is recorded by sha256 digest, media type, and size, because 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, with nothing dropped.

Milieu chooses the topic in the following order of precedence, most specific first:

  1. Explicit: act --topic <topic> (or the topic field on the REST run API).
  2. Project: A project-scoped run (--in <project>) files under the project.
  3. Classified: Otherwise, the model files the conversation under an existing topic, or invents a new kebab-case one when none fit.
  4. miscellaneous: The fallback when nothing else applies.
echo "I need a refund for order 48213" | ./milieu act --as mira --topic refunds triage

Like memory, transcripts feed runs in two ways. Passively, an index of the topic’s recent transcripts is prepended to the system prompt. 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.

milieu cleanup reclaims transcripts (and orphaned attachment blobs) 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 30d

cleanup only reclaims derived data (blobs, transcripts, and settled messages). It never deletes memory or knowledge.

Messaging between agents

Agents collaborate by message. There are 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 1

Having 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
arun sends a message, milieu route delivers it to mira's inbox, wake runs mira's respond skill, and route delivers the reply back to arun's inbox.

--auto dispatches each message to the model-selected skill (with --skill as the fallback). To remove the manual route and wake steps entirely, run the daemon. For details, see OPERATIONS → The daemon.

Synchronous request/reply

ask sends and blocks until the reply arrives, printing only 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 30s

Exit codes: 0 reply received, 1 send or setup error, 124 timeout.

Consulting another agent within a run

ask is a request between agents, carried by the daemon. A skill that needs another agent’s answer before it can reply (for example, a front desk routing to specialists) consults instead: the built-in consult tool runs the other agent’s turn and hands back its answer as the tool result, inside the same run, so the client keeps one conversation with one agent.

# the front desk, in milieu.yaml
permissions:
  - "consult(vehicle-inspection:*)"
  - "consult(driving-licence:*)"
<!-- in its respond skill -->
allowed-tools:
  - consult(vehicle-inspection:*)
  - consult(driving-licence:*)
---
Work out which service the question is about, then consult that agent
(consult, args=["driving-licence", "<the question, with every detail it needs>"])
and give the client its answer, saying whose it is.

The consulted agent runs its respond skill with its own permissions and knowledge, one-shot and unscoped, and its transcript is filed under the topic consulted-by-<caller>. It sees only the question, never the conversation, so the question has to stand on its own. If it needs something only the client can give, the result says so first: the caller asks the client (with session request-input) and consults again with the answer. A consulted run can’t consult in turn, and an auditor can’t consult at all. Over A2A, a stream shows consulting <agent> while it runs.

By default, the answer comes back to the calling model, which writes the reply. When the specialist’s answer is the reply (a front desk that only routes), that second pass costs a model call and rewrites what was already right. If you set consult: relay in the skill’s frontmatter, the first consulted answer becomes the run’s reply as it stands, and the run ends. If the specialist is waiting on the client, so is the conversation. An empty answer isn’t relayed, so the model sees it and carries on. Lint warns about a relaying skill that can’t consult.

A client asks the front desk, which consults a specialist with the question only. The specialist runs its respond skill and its answer returns as the tool result. By default the front desk writes the reply from it. With consult relay, the answer goes to the client as it stands.

Addressing by subject, not by name

Both commands can name what a message is about instead of whom it’s for, and let the domain model decide where it goes. This requires the recipient to have declared what it answers for, under [ontology] owns in its agent.toml:

echo "how long is the window?" | ./milieu message --as arun send --about refund "refund window"
# queued message 1 -> mira (accountable for domain:refund)

echo "has it shipped?" | ./milieu ask --as mira --about supplier/parcelco
# milieu: arun is accountable for domain:supplier/parcelco

A sender needs to know the vocabulary but not the roster, and a skill that would have named a colleague names the subject instead. Reorganising then means changing one declaration, not every skill that knew who to ask. --in <project> resolves in that project’s view, for a subject that a department declares.

Resolution is exact. A subject nobody answers for is refused, and the refusal names who owns what it’s a kind of, so you know which declaration to add:

error: no agent is accountable for domain:credit-note (credit-note); mira owns
domain:refund, which it is a kind of. Subjects resolve exactly: declare
domain:credit-note under [ontology] owns, or address the agent directly

It doesn’t walk up to the owner of a broader class: routing a question about one supplier to whoever owns suppliers in general is a guess that you never made and can’t see. Two owners are refused for the same reason, and so is addressing yourself. milieu graph reach <node> shows who answers for what.

Projects

A project is a named workspace that bundles instructions (prepended to skill prompts for in-project runs), a member roster (which gates who can act and who can reach the project’s knowledge and 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 acme

Seed 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 + global

Acting in the project applies the member check, prepends the instructions, and lets the built-in tools resolve the full union:

echo "When does ACME launch?" | ./milieu act --as mira --in acme runbook-lookup

A non-member gets agent "mira" is not a member of project "acme". Without --in, act runs against the agent and global layers only.

Departments: projects under projects

A project can sit under another. Whatever holds for a whole department (an escalation rule, a supplier’s contact, the runbooks every team uses) goes in the department’s project once, and every project under it reads it:

./milieu project create customer-ops --name "Customer operations" --level department
./milieu project create acme --parent customer-ops --level account
./milieu project set support-desk --parent customer-ops   # move an existing one
./milieu project tree
./milieu project show acme                                # reads: acme → customer-ops → global

A run --in acme now reads the agent layer, then acme, then customer-ops, then global, and a memory it saves lands in acme. Reads go up the tree and never down: a member of customer-ops doesn’t reach acme’s layers, and acting in acme still needs acme membership. A memory from a higher layer is labelled with its project ([project/customer-ops]) in the index that a run sees.

The acme and support-desk projects each read customer-ops, which reads the global layer. Mira acts in acme as a member, and Vera oversees customer-ops.

Moving a project changes what it and everything under it reads, with no file moved, so project set --parent prints each chain that changed and writes an audit row. In a manifest, parent: does the same through apply, which reports the move and records it in applied history.

project rm removes a project and says who it reached:

./milieu project rm acme
# removed project acme and everything its layer held
#   it was read by mira
#   it was overseen by vera

It refuses a project with children until you’ve given them another parent. Otherwise, a child would read a chain through a unit that’s gone, and you, not the command, decide where it belongs instead. It also refuses a layer holding knowledge or memory, unless you pass --force:

error: project layer is not empty: acme holds 3 knowledge documents and 1 memory,
which nothing else keeps a copy of; pass --force to remove it anyway

That content was written in place and nothing snapshots it, unlike a skill, which every build records. Oversight grants naming the project are withdrawn with it, so nobody is left holding a grant that reaches nothing. The audit log keeps every row that named the project: it’s append-only, and those runs still happened.

Oversight: reading down without joining

Membership never reads down, so an agent whose job covers every project in a department (a quality reviewer or an auditor) is given oversight, which saves adding it to every roster:

./milieu project overseer add customer-ops vera
./milieu project show acme            # overseers: vera (via customer-ops)
echo "Were ACME's credits on time?" | ./milieu act --as vera --in acme review

Vera can act in customer-ops and in every project under it, one at a time, and reads their knowledge and memory as a member would. Oversight writes nothing: a memory saved during that run lands in Vera’s own layer, and the tool says which layer it used. A knowledge --in acme save is refused. In a manifest, the grant is oversees: [customer-ops] on the agent. A plan shows a changed grant, and a project created under an overseen one is reported as visible to its overseers.

Charters: what each unit is for

A charter says what the organisation, and each project, is for. It’s a charter.yaml declared in the manifest, one for the organisation and one per project that has something to say:

# charter.yaml (kind: Organisation, spec.charter)
name: Example Broadband
vision: Nobody has to chase us for an answer.
mission: Keep households connected, and put it right quickly when they are not.
objectives:
  - id: first-contact
    statement: Resolve most contacts at the first touch, within policy.
# projects/acme/charter.yaml (the acme Project's charter:)
mission: Keep ACME's sites inside their one-hour SLA.
objectives:
  - id: one-hour-credit
    statement: Credit an SLA breach within the hour.
    advances: [customer-ops/one-touch-80]
    until: 2027-03-31

advances names an objective in a higher project, or in the organisation, as <project>/<id> or global/<id>. These links form the tree that says why the work under each objective exists. charter show prints what a run is told and the digest it records:

./milieu charter show --in acme

Every run is told the organisation’s name, mission, and vision. A run scoped to acme is also told the mission of every project in its chain, outermost first, and acme’s own objectives with the path each one advances. An ancestor’s objectives appear only on that path, and a lapsed one is left out. This context sits in the prompt right after the agent’s brief, capped at 3 KB, and the run’s audit row and transcript carry its context_digest. milieu lint reports an advances that names anything but a higher objective, a lapsed objective, and a chain whose charters compose past the cap. It also reports an objective that advances one whose statement changed after it was itself last stated, naming the change and who applied it: acme/credit was written to serve what customer-ops/one-touch used to say. Restating credit clears it. graph history objective:customer-ops/one-touch lists each statement it has had.

A project’s own vocabulary is told the same way. The terms that its own ontology modules declare (a department’s and the house glossary are left out) are listed under the charter, each with the node it means or its definition:

Terms used in acme:
- service credit: means domain:refund (Service credit)
- sla breach: A missed one-hour response.

They share the charter’s cap and its context_digest, and charter show prints them. Terms from further up the tree reach an agent through the graph tool.

Department context: what a department tells everything below it

A project’s instructions.md reaches every project under it. A run --in acme is told customer-ops’s instructions as a section of their own, headed by its level and name, immediately before ACME’s own instructions:

# Department context: Customer operations

Escalate any contact that mentions a regulator to arun, whatever the account.

Each higher project contributes a section, outermost first, so the project’s own instructions are read last, nearest the skill. A project with no level is headed Context from <name>. Department context shares the charter’s 3 KB cap, with the charter fitted first. It’s part of the context_digest a run records, and charter show --in acme prints it. Lint warns when a chain’s charter and department context together pass the cap.

Domain model

The domain model describes what the organisation works with: the classes of thing, the relations between them, the reference instances that policies are written about, and its vocabulary. It’s declared in ontology modules, one YAML file each, under var/global/ontology/ for the house model and var/projects/<code>/ontology/ for what holds within a project. For the whole format, see REFERENCE → Ontology modules.

# var/global/ontology/support.yaml
classes:
  - { name: customer, keyed_by: customer_id, resolved_via: route:crm }
  - { name: refund, aliases: [credit] }
  - { name: credit-note, subclass_of: refund }
  - { name: sla-tier }
relations:
  - { name: entitled_to, from: customer, to: sla-tier }
instances:
  - { class: sla-tier, key: gold, label: Gold }
# var/projects/acme/ontology/acme.yaml
classes:
  - { name: refund, aliases: [service credit] }   # ACME's word for it, in ACME's view only
instances:
  - { class: customer, key: acme, label: ACME Corp, entitled_to: sla-tier/gold }

In a manifest, the modules are declared like everything else, one Ontology document per set, and applied, audited, and exported with the rest:

apiVersion: milieu.dev/v1
kind: Ontology
metadata: { name: acme }
spec: { project: acme, sources: [./projects/acme/ontology] }

Browse it the way a run scoped to a project reads it:

./milieu graph classes --in acme
./milieu graph show refund --in acme                  # ACME's aliases, the house subclass
./milieu graph path customer/acme sla-tier/gold --in acme
./milieu graph trace objective:global/first-contact   # the objectives below that advance it
./milieu graph trace project:acme                     # what acme pursues, and what that serves
./milieu graph reach supplier/parcelco                # what documents and owns it, and who can get to it
./milieu graph orphans                                # what nothing documents, advances or connects
./milieu graph history refund                         # what refund meant each time it changed
./milieu graph export --in acme > acme.ttl            # Turtle; --format jsonld or dot

A view is the global layer, then each project in the chain from the outermost down. A nearer declaration of a node replaces its label, description, and aliases. Edges accumulate, and a project can remove an edge it inherits with a retracts: entry that gives the reason. The project tree and every charter’s objectives are part of the graph as well (project:<code>, objective:<unit>/<id>). They’re derived from the tree and the charters and need no declaration. Nothing is indexed: a view is read from the files each time, so an edit is in the next read.

milieu lint checks the model and reports each problem against the layer that declared it.

What documents, memories, and skills are about

A knowledge document or a memory says what it’s about in its frontmatter, and a skill names the classes it acts on in its own:

---
type: policy
title: Refund policy
about: [domain:refund, domain:sla-tier/gold]
---
---
name: respond
description: Answer a customer
acts-on: [customer, refund]
---

Each claim is an edge, read from the file on every view: the refund is documented_by knowledge:global/articles/refund-policy, noted_by any memory about it, and acted_on_by skill:mira/respond. graph show refund lists them with the rest of its edges. A view reads the knowledge and memory along its chain and every agent’s skills. An agent’s own knowledge and memory are in the operator’s view and in that agent’s own runs, and in no other agent’s. A claim grants nothing: what a skill can call is still its allowed tools and its agent’s permissions.

The rest of Milieu says what it reaches the same way. A gateway route names the class whose live records it fetches, or the one party it reaches. An agent names what it answers for, and a project names the part of the domain it works in:

kind: Agent
metadata: { name: arun }
spec: { role: associate, ontology: { owns: [supplier/parcelco] } }
---
kind: Project
metadata: { name: customer-ops }
spec: { level: department, ontology: { scope: [supplier/parcelco, refund] } }
---
kind: Gateway
metadata: { name: default }
spec:
  routes:
    - { name: crm, prefix: /crm, agents: [mira], upstream: https://crm.example/api, ontologyClass: customer }
    - { name: parcel-tracking, prefix: /parcels, agents: [mira], upstream: https://api.parcelco.example,
        ontologyInstance: supplier/parcelco }

They land in agent.toml and project.toml as an [ontology] block and in gateway.toml as ontology-class and ontology-instance. graph show supplier/parcelco then says ParcelCo is exposed_by route:parcel-tracking, owned_by agent:arun, and scoped_by project:customer-ops. Owning something grants nothing: Arun still reaches ParcelCo only if a permission lets him call the route.

milieu lint reports a claim naming a node that isn’t declared, an acts-on: or ontologyClass naming anything but a class, and an ontologyInstance naming anything but an instance.

With the claims in place, graph reach answers who can get to what:

$ ./milieu graph reach supplier/parcelco
domain:supplier/parcelco  ParcelCo
  ├─ owned_by       agent:arun
  ├─ scoped_by      project:customer-ops  [customer-ops]
  └─ exposed_by     route:parcel-tracking  held by mira   ✗ not held by arun, who owns it

An agent holds every gateway route when a permission grants it the gateway tool and its agent.toml enrols it, and none otherwise. graph reach agent:arun turns the question around: what Arun answers for, what his skills act on, and whether he can get to any of it. graph orphans is the review list: classes nothing documents, nodes documented only by stale knowledge, department and organisation objectives that nothing under them advances, and claims about nothing.

A verified document stays verified only while what it’s about means the same thing. When an Ontology apply changes the definition of a node that a document is about: after the document’s newest verified entry, knowledge get and knowledge ls label it, in the CLI and in the knowledge tool alike. The definition is the node’s description, superclass, key, route, class, or part. A change to its label or aliases doesn’t count:

articles/refund-policy.md  Refund policy  [domain:refund changed on 2026-09-05, after this was last verified]

milieu lint reports the same document with who applied the change and in which declaration. Verifying the document again clears it. Search results aren’t labelled. To see a hit’s caveats, open it with get.

graph why explains a run after the fact. Take a run’s seq from milieu audit and ask what it rested on:

$ ./milieu graph why run:8231
run:8231  mira  act respond --in acme
  2026-09-13 14:02:00 – 14:02:50 UTC  skill sha256:0123456789ab
domain:refund  Service credit
  ├─ knowledge:global/articles/refund-policy  read at 14:02:10  (verified 2026-01-01)
  └─ memory:agent/mira/refund-window  recalled at 14:02:11  (an agent's own note)
domain:customer  Customer
  └─ route:crm  called at 14:02:20
about nothing in the model:
  knowledge search refund window at 14:02:05

The four-hour threshold in that reply came from Mira’s own note, not from the policy, and the explanation says so without anyone reading the transcript. A run’s calls are the built-in calls its agent made while it ran, so a run that overlapped another by the same agent says so. A search is listed, but not what it found.

An old run is explained against the model as it was, not as it is. When a part of the domain has been redefined since, why reads what it meant from the declaration in force when the run ran:

domain:refund  Service credit
  ├─ knowledge:global/articles/refund-policy  read at 14:02:10  (verified 2026-01-01)
  ├─ meant "Money back" when the run read it
  └─ its definition changed on 2026-09-05, after this run (Ontology/house)

The label stays today’s, because it’s how you find the node again, and because a rename isn’t a change of meaning. If retention has swept the snapshot that would show the old meaning, why says so instead of showing today’s meaning against yesterday’s reply.

Letting an agent walk the graph

The domain model reaches an agent through the built-in graph tool, which the agent calls when it needs to know what something is. Nothing is added to the prompt. Grant it like any other tool, whole or a verb at a time, and declare it in the skill:

./milieu permission grant mira graph

cat > .milieu/var/agents/mira/skills/respond.md <<'EOF'
---
name: respond
description: Answer a customer
allowed-tools:
  - knowledge(search:*)
  - graph
---
Before you answer, find what the customer is talking about in the domain model
(args=["find","<their words>"]) and show it (args=["show","<node>"]): what it
is, the tier or supplier it relates to, and the route its live records come
from. Then search the knowledge base using the names you found.
EOF

echo "Has the courier collected my router?" | ./milieu act --as mira --in acme respond

A run scoped --in acme walks ACME’s view: the house model, the modules of every project above ACME, and ACME’s own. An unscoped run walks the global layer alone. The verbs are classes, find <words>, show <node>, neighbors <node> [1|2], and path <a> <b>. The tool is read-only. Each call is audited like any other built-in, and a code script reaches the same verbs as graph(*args) when the tool is granted.

Proposals: what agents reach for

An agent that needs a relation the model doesn’t have can say so. Grant the verb by name (graph alone doesn’t carry it), and the agent can propose an edge between nodes that exist:

./milieu permission grant mira "graph(propose:*)"

In a run, graph propose customer/acme entitled_to sla-tier/gold "ACME's contract names the gold tier" is checked against the model at once: a relation between the wrong kinds of thing, an edge the model already has, or one a person rejected is refused with the reason, and one run can propose at most three. What passes waits in a queue, and nothing in it is in the model:

$ ./milieu graph proposals
3f9c2a1b7e40  ×4  domain:customer/acme entitled_to domain:sla-tier/gold  [acme]
    why:      ACME's contract names the gold tier
    last:     mira/respond at 2026-09-14 10:12; first 2026-09-11
    had read: domain:customer/acme

Identical proposals are counted, so the ones at the top are the relations runs most often need. Accept one into the module file that declares its subject (the source file your manifest applies), and review the change before making it:

./milieu graph propose accept 3f9c2a1b7e40 --file ontology/acme.yaml           # prints the diff
./milieu graph propose accept 3f9c2a1b7e40 --file ontology/acme.yaml --write   # makes it
./milieu apply -f milieu.yaml                                                  # puts it in the model
./milieu graph propose reject 3f9c2a1b7e40 --reason "Gold lapsed in August."   # or decline it

A rejected edge isn’t queued again, and the next agent to propose it is told why. The graph propose reset <id> command forgets it when the model has moved on, and every decision is audited with the operator who made it.

A proposal that passes its checks is queued. Accepting it with --write marks it accepted, and apply puts it in the model. Rejecting it marks it rejected, and reset forgets it.

Retrieval through the domain model (on by default)

Knowledge search matches words. A customer who asks for a “service credit” finds nothing in a policy that calls it a reduction on the next invoice, even when the model says service credit is an alias of domain:refund and the policy says it’s about: a refund. A search therefore links the query’s words to the nodes of the run’s view and appends what’s attached to them, and to the classes one subclass_of step away, after the text matches, at most five:

articles/credit-notes.md  A credit note is raised within five working days…
articles/compensation.md  (reached through the domain model, not a text match: about domain:refund; the query names it by alias "service credit")

A memory search fuses the same way over the notes that are about: a node, and a knowledge search also names the route that exposes a party the query names, so a run asking about the courier is told it can reach ParcelCo:

route:parcel-tracking  (reached through the domain model: exposes domain:supplier/parcelco; the query names it by alias "the courier")

A route is named only to an agent granted gateway, last and as itself, because it isn’t a document and knowledge get won’t open it. milieu knowledge search fuses the same way, so you can see what a run would have received.

A reached document passes the same filters that a text match does, and is labelled so the model can tell a neighbour from a match.

It’s on by default, because the suite that gated it went from 49/60 to 60/60 with it on, left its control untouched, and cost about 14% fewer tokens: finding a document on the first search saves a whole search-rephrase-search turn, which is more than the larger result costs. That was three documents on one model, so if your corpus is one whose words already find what your readers mean, score it yourself with examples/domain-retrieval and milieu eval compare, and set MILIEU_GRAPH_RETRIEVAL=off where it doesn’t help.

Multimodal input (images and PDFs)

act and message send take a repeatable --attach <file> (PNG, JPEG, GIF, WebP, or PDF). Anything else is rejected before any API call. Attachments require 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.pdf

Attachments on messages travel 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 or 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 route

Worked 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:

ExampleDemonstrates
contact-centerMulti-role support desk inside a department, charters, domain model, gateway-brokered customer relationship management (CRM) token, and an external chat widget over the API
legal-expertThree-layer knowledge (global statutes and project clauses), a read-only reviewer that oversees the project, clause types, and review terms
process-auditSOC 2 readiness, dated objectives, controls as a domain model, a tamper-evident audit log, and a read-only logs broker
data-analystData-dictionary knowledge, one domain node per metric, a gateway warehouse route, and SQL named queries

Start with examples/contact-center for a tour of agents, a project, the gateway, and the API together.