Skip to content

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.jsonl carries the same array verbatim at session_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>, which session_start.session_id supplies.
  • 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:

  1. task.md
  2. input.txt
  3. 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.