Session Workdir
Every session runs against two directories: the one the capsule can see, and the one the runtime keeps its bookkeeping in.
| Directory | Path | Holds |
|---|---|---|
| Accessible workdir | The directory passed to mur run --workdir, otherwise <manifest-dir>/workdir/<session-id> |
The capsule's current directory — its task, everything it writes, and the $HOME its shell commands run under |
| Session workdir | <accessible-workdir>/.murmur/<session-id> when --workdir is passed, otherwise the accessible workdir itself |
Runtime bookkeeping: staged artifacts, results, traces and logs |
Without --workdir the two are one directory and everything below lands in the same place.
To find what the capsule changed, diff the accessible workdir and subtract what the runtime wrote
there. mur run --explain-scope enumerates those paths, as runtime_writes under --json:
- That enumeration is authoritative; the tables below describe what each path holds.
trace.jsonlcarries the same array verbatim atsession_start.effective_grants.runtime_writes, so a run reports the paths to subtract from it.- Paths are relative to the accessible workdir and carry the literal segment
<session-id>, whichsession_start.session_idsupplies. - Excluding the single prefix
.murmur/covers every runtime-written path outside the accessible workdir's own top level.
Two more directories sit outside both and outlive every session: a
durable state store, for artifacts that ask for one by name, and the
conversation record, which every http capsule keeps by default.
Task input
The agent reads its task from the accessible workdir at the start of every task, taking the first of:
task.mdinput.txt- Neither, in which case the agent starts with an empty task.
task.md has three writers:
| Writer | When |
|---|---|
mur run --task <value> |
Before launch. A value naming an existing file is copied; anything else is written as text |
| The runtime | On each incoming A2A message, and again when an on-task-end hook returns reopen-task — rewritten as the original task plus every reopen's feedback so far |
| The capsule | Through its own file tools, like any other file in the accessible workdir |
Under lifecycle.task_acceptance: queue the runtime deletes task.md after each task, so the next
task comes from the queue rather than from a stale file.
Session workdir files
| Path | Notes |
|---|---|
MURMUR.md |
The capsule's generated inventory: identity, directory layout, installed tools and skills, shell access. Agent sessions only. Written at staging and rewritten once the capsule's port is bound |
trace.jsonl |
One JSON object per session event. See Observability schemas |
eval.jsonl |
Scorer output for the session. mur eval reads it after each case. See Observability schemas |
out/result.txt |
The agent's final output. Written on every terminal outcome; a failure writes error: <message>, and a turn stopped at inference.max_tokens writes the model's partial text followed by a truncation marker naming that field and the cap |
out/result_<task-id>.txt |
Per-task copy of the final output, so one task does not overwrite another's. Only under lifecycle.conversation: threaded |
out/compaction-summaries.jsonl |
The text each committed compaction replaced the context with. See below |
logs/bootstrap.log |
Staging and agent-loop diagnostics: the installed tool inventory, compaction decisions, and non-fatal write failures |
logs/otel.log |
OpenTelemetry exporter diagnostics |
logs/hook-<hook-name>.log |
Errors from one hook, one per line |
tools/<name>/murmur.yaml |
A staged artifact's manifest |
tools/<name>/<name> |
A staged native binary, marked executable |
tools/<name>/skill.md |
A staged skill's text. The runtime returns this as the tool result when the skill is called |
blobs/<sha256> |
Trace bodies too large to inline, addressed by their own digest. See Observability schemas |
plans/plan-<n>.json |
One submitted plan per file, numbered by this session's own counter |
.capsule-home |
$HOME for every shell command and native tool. Created on the first such call. Reached only through the variable |
.mur-tmp |
Backs /tmp inside a sealed capsule's composed root. The capsule sees /tmp. Counted against capabilities.resources.workdir_max_bytes like everything else in the workdir |
.mur-etc |
Holds the passwd and group a sealed capsule's composed root binds at /etc/passwd and /etc/group |
Accessible workdir files
| Path | Notes |
|---|---|
task.md, input.txt |
The task, as above |
murmur.yaml |
A copy of the project manifest, so the agent can reference it by relative path. Only under --workdir, and never over a file already there |
.murmur/<session-id>/ |
The session workdir |
.murmur/children/<capsule>-<suffix>/ |
One directory per sub-capsule launch, each the child's own accessible workdir. Created mode 0700 |
logs/shell-<timestamp>.log |
Full stdout and stderr of one shell command, written when either stream exceeds 16 KB. The tool result carries the path |
logs/<work-id>.log |
The same, for a command demoted to the background. The completion carries the path |
peer-in/<handle-id>-<name> |
Bytes fetched by redeeming a peer handle. See Resource plane |
out/result.txt |
A script capsule's final output. An agent session writes it to the session workdir instead |
completion.json |
How a delegated child ended, read by the launcher that started it |
checkpoints/ |
summary.md, plan.json and decisions.json. MURMUR.md directs the agent to write state here to survive compaction; the runtime neither reads nor writes them |
Everything else in this directory belongs to the capsule.
A sealed capsule adds nothing to this list: its /tmp store and its synthetic /etc staging are
both in the session workdir.
Durable state store
An artifact that declares capabilities.state is granted one directory outside both workdirs:
| Path | Lifetime | |
|---|---|---|
| Session workdir | <accessible-workdir> or <accessible-workdir>/.murmur/<session-id> |
One session |
| Accessible workdir | --workdir, otherwise <manifest-dir>/workdir/<session-id> |
One session, unless --workdir names a directory you keep |
| State store | ~/.murmur/state/<store>/ |
Every session of that capsule, on that machine |
The store is mounted into the guest as a second WASI preopen named state, alongside the workdir
mounted as .. Guest code reaches it with an ordinary relative path:
std::fs::write("state/notes.jsonl", contents)?; // the store
std::fs::write("out/result.txt", summary)?; // the workdir
~/.murmur, ~/.murmur/state/ and each store directory under it are created mode 0700, and the
mode is reasserted on every launch. See ~/.murmur modes.
What distinguishes it
It is keyed by capsule, not by directory. The store name comes from capabilities.state.store,
defaulting to the capsule name — never from the workdir, the session id or the machine's layout. A
launch that gets a fresh <manifest-dir>/workdir/<session-id> reads back exactly what the previous
launch wrote.
It sits outside every workdir, and only the artifact that declared it can reach it. A subtree
of the workdir would be readable by anything holding the workdir preopen — murmur-tool-editor and
shell included — and capabilities.filesystem.scope cannot help, because it is a single path
prefix: protecting one subtree would mean narrowing every other artifact. The capsule's own code
declares no artifact grant and reaches no store.
Each capsule gets its own store. Two capsules launched in the same directory get two stores and
cannot see each other's, with or without a declaration on either side. Sharing between capsules
goes over A2A, with a grant on both ends. Declaring the same store: name in two capsules is the
one way to point them at one directory, and it has to be written in both manifests.
WASM tools, drivers and hooks reach the store; native subprocesses do not. Under the sealed
containment class the store is absent from the capsule's composed root, so neither a binary
allowlisted by capabilities.shell.allow nor a sub-capsule allowlisted by
capabilities.spawn.allow can open it.
What belongs in the workdir instead
Per-project notes. The accessible workdir already is the project: notes about the repository the capsule is working in belong beside that work, where they move, get committed and get deleted with it. A store keyed by capsule would carry them from one project to the next.
The store is for what transcends workdirs — a corpus, a learned index, an append-only memory log that means the same thing whichever directory the capsule was launched from.
Reporting
mur run --explain-scope lists every declared store under Effective grants:
state stores:
- murmur-tool-corpus: shey -> /home/dev/.murmur/state/shey
--json emits the same list as state_stores, and trace.jsonl's session_start carries it
verbatim as effective_grants.state_stores. Declaring a store changes no other field of the
report: it is a directory grant, not a containment property, so declared_containment,
achieved_containment, floor_met and enforcement_tier are unmoved by it.
--explain-scope resolves and prints host paths without creating any of them. Only a real launch
creates a store.
See Tool and driver capabilities, Hook capabilities and State store name.
The conversation record
Every task on inference.transport: http appends the messages it puts in front of the model to one
durable file:
~/.murmur/conversations/<record>/<context-id>/conversation.jsonl
| Segment | Value |
|---|---|
<record> |
context.record_store, defaulting to the capsule name |
<context-id> |
The task's context id: the contextId an A2A client sent, the value of mur run --context, the id mur run --resume looked up from a previous session, or a fresh ctx_… per task |
Two runs given the same context id continue one conversation, whether they arrive over A2A or from
two mur run --context <id> launches with no session directory in common. mur run --resume
<session> reaches the same record without you having to know the id: it reads the context off that
session's trace.
A record whose capsule declares context.retain opens with a
header line. Every other line is one message.
One line is one message, as the runtime holds it:
{"role":"user","content":[{"type":"text","text":"Summarize today's changes."}],"id":"msg_01a04900754b7183b66c11e744612e2d"}
role, content and id are always present. id is msg_ plus a uuid-v7, minted once where the
message was created and preserved everywhere after — including across a reload and across a hook
that hands the message back. It is the runtime's own and is stripped before the messages go to a
driver. A tool message also carries tool_call_id and is_error.
Four more keys are the runtime's own envelope, each written when it applies and each stripped before the messages go to a driver:
| Key | Written on |
|---|---|
source_id |
A message a hook produced, when that hook supplied one |
canceled |
The assistant message left behind by a turn a person stopped |
truncated |
An assistant message the provider cut off at inference.max_tokens |
fence |
A message whose content is wrapped in the untrusted fence — the source name, tool:<artifact name> or task:<origin> |
The record holds every message that enters the context, in the order it enters: the task's user
message, each committed seed-context message, each assistant message, each tool result, and each
message a compaction commits — beside, not instead of, the messages it replaced.
context.retain is what bounds it, and
mur conversation is what an operator prunes it with by hand. Without
a retain: block the record grows without bound.
The header line
The first line of a record whose capsule declares context.retain, and of any record
mur conversation truncate has rewritten:
{"type":"murmur.record","capsule":"shey","created_ms":1756400000000,"truncated":{"dropped":500,"oldest_surviving_id":"msg_01a0490075…","last_dropped_id":"msg_0199f2a1c3…","at_ms":1756400000000}}
| Key | Value |
|---|---|
type |
Always murmur.record. A first line carrying anything else is not a header |
capsule |
The capsule that owns this record — name: from its manifest, not the record store. What makes pruning safe when two capsules share one context.record_store |
created_ms |
When the header was written. On an adopted record, that is the adoption, not the conversation's first message |
truncated |
Absent until the record has been truncated. dropped is cumulative over the record's life; last_dropped_id is what mur conversation ls --message classifies a missing id against |
It is a JSON object with no role, so it is not a message: every reader of a record skips it, the
total a murmur:conversation/read page reports does
not count it, and a threaded reload does not load it.
A record with no header line is unowned: the age sweep skips it however old it is. The next launch
that opens it under mur run --context writes the header and applies its policy from that launch.
What pruning removes
| Policy | Removed |
|---|---|
trace.retain |
A whole <session-id>/ directory under the workdir, including its trace.jsonl and its blobs/ |
context.retain.max_messages |
The oldest message lines of one conversation.jsonl. The file, the directory and every surviving id stay |
context.retain.max_age |
A whole <context-id>/ directory under the record store |
Pruning runs at launch, in the runtime, immediately after the session's session_start. Every
deletion is written to that session's own trace as a
retention event.
What it means for a run
It is written as the context is built, not at the end. A task that fails, and one that spends
inference.max_turns, have both already recorded everything they sent.
lifecycle.conversation governs loading, not recording. A stateless capsule appends to its
record like any other and simply starts every task from nothing;
threaded starts a task from the whole record for its
context. mur run --resume loads the record either way, for that launch only.
Turning it off creates nothing. context.record: off means no
~/.murmur/conversations/ directory at all. So does inference.transport: process, whose CLI owns
its own conversation.
A failure to write never fails a task. An unresolvable HOME, a full disk or an unwritable
directory is reported once to stderr and to logs/bootstrap.log, and the task runs on unrecorded.
Reading it from an artifact
No artifact ever gets a filesystem path into ~/.murmur/conversations/. The only way in is
murmur:conversation/read, granted per hook with
capabilities.conversation.read: true.
| Path | Mode |
|---|---|
~/.murmur, the conversation root and each directory under it |
0700 |
conversation.jsonl |
0600 |
Each mode is set again on every append and every rewrite.
out/compaction-summaries.jsonl
Written only when the manifest sets inference.compaction.dump_summaries: true (default false;
see inference.compaction.dump_summaries). One line per committed
compaction, appended in the order compactions occur:
{"turn":17,"tokens_before":81501,"tokens_after":334,"summary":"1. THE BUG: ..."}
| Field | Type | Description |
|---|---|---|
turn |
integer | The turn the compaction fired on |
tokens_before |
integer | Session token count immediately before compaction |
tokens_after |
integer | Session token count immediately after the replacement context committed |
summary |
string | The text of the committed replacement context, tool messages excluded |
A line is written only after a compaction hook returns replace-context and that replacement
survives the tool-call-pairing check and commits. A rejected replacement, or a session where no
hook returns replace-context, appends nothing — so the file appears on the first successful
compaction, and a run that never compacts leaves none behind.
A write failure is logged to logs/bootstrap.log and does not fail the session; the compaction has
already committed by then.
This log is the only place the summary text is kept. The compaction event in trace.jsonl records
the turn and the two token counts, not the text.
Lockfile (murmur.lock)
murmur.lock sits in the project directory beside murmur.yaml, not in the workdir. It pins every
registry-resolved artifact to a version and a hash:
lock_version: 2
artifacts:
- name: some-tool
resolved_version: "1.2.3"
sha256:
any: "<sha256>"
- name: some-native-tool
resolved_version: "0.4.2"
sha256:
platforms:
darwin-aarch64: "<sha256>"
linux-x86_64: "<sha256>"
lock_version must be 2. An entry carries exactly one of two hash shapes:
| Key | Pins | Written for |
|---|---|---|
sha256.any |
One hash for one payload every host resolves | A WASM component or a static skill |
sha256.platforms.<platform> |
One hash per platform tag | A native tool, whose payload is a different binary per platform |
A platform tag is one of darwin-aarch64, darwin-x86_64, linux-aarch64, linux-x86_64 — the
same tags mur publish --platform writes and the store files a payload
under.
A host verifies against the hash pinned for its own platform. A native artifact with no key for
this host is not pinned here yet — run mur install on this host to add it, which leaves every
other platform's key intact.
Every path below verifies against the pin before writing anything: a registry-resolved artifact whose version or hash disagrees with an existing entry is rejected, with nothing written to disk or to the lock. A platform the entry has no key for is not a disagreement.
| Path | When it writes |
|---|---|
mur run |
Creates murmur.lock when none exists. Once present, only verifies against it — an existing entry is never refreshed |
mur eval |
As mur run, once for the whole dataset run |
mur install |
Upserts an entry for each artifact it installs successfully, preserving the rest. Skipped for -g (no project directory), for local-file installs, and for --all-platforms, which installs into the global store |
manage.pull() |
The same verify-then-upsert, from a running capsule rather than the CLI |
An upsert adds this platform's key beside the keys already there. It replaces the whole sha256
block only when everything in it is stale: a different resolved_version, or a change between the
any and platforms shapes.
A lock_version: 1 file is refused rather than migrated — it pinned one sha256.wasm per
artifact, which cannot describe a native artifact's per-platform payloads. Delete murmur.lock and
run mur install to regenerate it.
A missing entry for a manifest artifact, an entry with no hash for this host's platform, or an
unsupported lock_version, fails the run with E-RUN-003. An install whose registry hash
disagrees with the pin fails with E-REG-005. See Diagnostics.