Session Directories
Every session has three locations: the work folder the capsule works in, the records directory holding what the runtime keeps about the session, and the runtime directory holding the files the runtime writes for its own use.
| Location | Path | Holds | The capsule's commands |
|---|---|---|---|
| Work folder | The directory passed to mur run --workdir, otherwise <manifest-dir>/workdir/<session-id>/ |
The capsule's current directory — everything it writes | Read and write |
| Records directory | ~/.murmur/records/<workdir root>/<session-id>/ |
trace.jsonl, blobs/, logs/, the agent's results in out/, the commands' home/ and tmp/, delegated children's work folders in children/, a delegated child's completion.json and a scoring hook's hooks/. Kept after the session |
Read only, where the host can enforce it, apart from home/ and tmp/, which they read and write |
| Runtime directory | ~/.murmur/sessions/<session-id>/runtime |
MURMUR.md, task.md, the manifest copy, installed tools and submitted plans. Removed when the session ends |
Read only, where the host can enforce it |
In a formation, the entry member's work folder is the roster's project
directory, and each peer's is ~/.murmur/formations/<frm_id>/<member>/ — see
Member directories.
Two more directories sit outside these 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.
The work folder
The runtime creates nothing in the work folder. A --workdir folder is left exactly as the agent
left it. The runtime reads the operator's task there, and writes in it only at the capsule's own
request:
| Path | The runtime | Notes |
|---|---|---|
task.md, input.txt |
Reads once | The operator's task, read when the session starts. See Task input |
peer-in/<handle-id>-<name> |
Writes, when the agent redeems a peer handle | The fetched bytes. See Resource plane |
out/result.txt |
Reads | A script capsule's final output, written by its own component. An agent's results are in the records directory's out/ |
Everything else in this directory belongs to the capsule. When the work folder is the project
directory, an artifact pull the agent is granted may update the project's
murmur.lock in it. Session folders that earlier runs left under
.murmur/ in a work folder are never read, written or removed.
A sealed capsule adds nothing to this list: its /tmp is the
session's temporary folder, its $HOME is the session's home folder,
both in the records directory, and its synthetic /etc files are beside the runtime directory.
What the capsule changed
To find what the capsule changed, diff the work folder and subtract what the runtime wrote there.
mur run --explain-scope enumerates those paths in its Runtime writes block:
Runtime writes
paths are relative to the work folder
runtime writes:
- peer-in (directory, peer-fetch)
- out (directory, script-session)
Under --json the same rows are runtime_writes, one object per path:
path |
kind |
condition |
|---|---|---|
peer-in |
directory |
peer-fetch |
out |
directory |
script-session |
- That enumeration is authoritative; the work folder table describes 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 work folder. The set is the same for every launch, with or without
--workdir, and on every tier.
Size ceiling
capabilities.resources.workdir_max_bytes bounds the
work folder, the records directory's tmp/ and its home/ together. The runtime walks the three
every 10 seconds and sums their sizes; crossing the ceiling ends the session with
E-RUN-013. MURMUR.md states the ceiling for every
inference.transport: http session.
Under --workdir the work folder is the whole folder you pass, including the files that were in it
before the run. For a folder near or above the 10 GiB default, raise workdir_max_bytes, or the
session ends at the first check.
Task input
The agent reads its task from task.md in the runtime directory. It gets
there one of three ways:
| Way in | When |
|---|---|
mur run --task <value> |
Before launch. A value naming an existing file is copied; anything else is written as text |
| The operator's file in the work folder | Once, when the session starts and the runtime directory has no task.md: task.md, else input.txt except under inference.transport: process. The work folder's file is left where it is and never read again |
| An A2A message | On each incoming message |
The runtime also rewrites it when an on-task-end hook returns reopen-task, as the original task
plus every reopen's feedback so far. When a task continues with
call-member answers on an attempt that restarts rather
than continuing its conversation, the rewrite also holds every batch of answers so far.
An agent receives the task as its first message; neither the [Capsule] block nor MURMUR.md
names the file. The capsule's commands can read the file by the names in
How the capsule reaches it, and cannot change it where the host can
enforce that — see What protects it. A task.md the capsule writes in its own
work folder is its own file.
A task whose text is empty or only whitespace fails without a request to the model, with a message saying the task is empty. A launch that receives no task at all ends after its idle timeout without a model call.
Under lifecycle.task_acceptance: queue with lifecycle.after_task: sleep the runtime deletes
task.md after each task, so the next task comes from the queue rather than from a stale file.
Runtime directory
Every file the runtime writes for its own use, and reads back as input, lives in a per-session directory outside the work folder:
~/.murmur/sessions/ 0700
└── <session-id>/ 0700, removed when the session ends
├── .lock held by the live session
├── runtime/ $MURMUR_RUNTIME_DIR
│ ├── MURMUR.md
│ ├── task.md
│ ├── murmur.yaml the launched manifest, under its own name
│ ├── plans/plan-<n>.json
│ ├── delegations/<delegation-id>.txt
│ └── tools/<name>/murmur.yaml | <name> | skill.md
├── sealed-etc/passwd, sealed-etc/group
└── harness/<run-id>/
A launch whose HOME is unset, relative, or names a directory in which ~/.murmur cannot be
created is refused with E-IO-004 before anything is created.
| Path | Holds |
|---|---|
runtime/MURMUR.md |
The capsule's generated inventory: identity (for a formation member, also its roster name and formation id), both directories, the network allowlist, the limits, the runtime directory's entries, installed tools and skills, shell access. See MURMUR.md. Agent sessions only. Written at staging, rewritten once the capsule's port is bound, and rewritten after every pull or removal that commits |
runtime/task.md |
The task. See Task input |
runtime/<manifest file> |
A copy of the manifest file the launch read — --manifest, or the project's murmur.yaml — under its own name. A capsule launched by name with --capsule read none and has none |
runtime/tools/<name>/murmur.yaml |
A staged or pulled artifact's manifest. <name> is the artifact's bare name, the part of its full name after the /, which is also its tool name |
runtime/tools/<name>/<name> |
A staged or pulled native tool, marked executable |
runtime/tools/<name>/skill.md |
A staged skill's text. The runtime returns this as the tool result when the skill is called |
runtime/plans/plan-<n>.json |
One submitted plan per file, numbered by this session's own counter, and read back when the plan runs |
runtime/delegations/<delegation-id>.txt |
The whole answer of a sub-capsule whose answer was cut at 64 KiB in what this session was handed — a delegate-task outcome or a plan capsule step's output. Present only for a cut answer; the cut line names it |
sealed-etc/ |
The passwd and group a sealed capsule's composed root binds at /etc/passwd and /etc/group |
harness/<run-id>/ |
The files a transport: process driver asked for, present while its harness runs |
How the capsule reaches it
| Reader | Where |
|---|---|
A shell command, a plan's shell step, a native tool, a transport: process harness |
$MURMUR_RUNTIME_DIR in its environment, the absolute path of runtime/. Set by the runtime last, so neither capabilities.env.allow, a shell baseline variable nor the host can change it. A shell interpreter expands it in a command; a native tool or a binary allowed on its own is handed its arguments as written, so a path argument must be absolute |
A script capsule's own component, a WASM runtime: tool artifact with no capabilities.filesystem.scope |
murmur-runtime/, beside the work folder it sees as its current directory, read-only. A script capsule reads its task at murmur-runtime/task.md |
A tool with a scope, a driver, a hook |
Nothing |
The model is told only the names its own tools can use: by the Runtime files: line of the
[Capsule] block, and by the runtime-directory entry
of MURMUR.md, which also gives the absolute path.
sealed-etc/ and harness/ are reached by no command.
What protects it
| Applied tier | runtime/ for the capsule's commands |
|---|---|
mountns+pivot_root+landlock+seccomp (sealed) |
Bound read-only at its own absolute path inside the composed root, and granted read-only by Landlock. A bind that fails ends the session with E-RUN-014 |
landlock+seccomp (scoped) |
Granted read-only by Landlock: reading and listing, no writing, no running. A native tool's entrypoint runs through its own grant |
seccomp-only |
Not protected: no filesystem mechanism |
none (macOS and every non-Linux host) |
Not protected: no kernel containment for capsule commands |
A command that tries to overwrite, append to, remove, rename over or create a file there gets
Permission denied under Landlock and Read-only file system inside a composed root; a hard link
from it into the work folder gets Invalid cross-device link.
What Landlock does not mediate on landlock+seccomp:
| Operation | What a command can do |
|---|---|
chmod, utimes, setxattr |
Change a runtime file's mode or times, never its contents. A file made unreadable fails the read that needs it |
truncate(2) at Landlock ABI 1 or 2 (kernels before 6.2) |
Empty or zero-extend a runtime file by path, without writing a byte of its own |
Inside a composed root the read-only bind refuses all of them.
A runtime directory inside the work folder
When ~/.murmur/sessions, or the session's records directory, lies inside the
work folder — HOME and --workdir the same directory, for one — the capsule's commands can
write it:
| Declared containment | Outcome |
|---|---|
scoped or sealed |
Refused with E-CAP-003, naming the directory and the work folder |
advisory |
The launch runs, prints W-SEC-033 and records achieved_containment: advisory |
Run with a --workdir that does not contain ~/.murmur.
Lifetime
| When | What happens |
|---|---|
| Staging, after every refusal that stops a launch | The directory is created and its .lock taken |
The session ends — success, a failed task, a refusal, a SIGTERM — or a staged session is never launched |
The directory is removed |
| The next launch's staging | Every ~/.murmur/sessions/<id>/ whose .lock is free and more than 60 seconds old, and every entry with no .lock more than 60 seconds old, is removed. A live session's lock is never free |
Reporting
mur run --explain-scope prints the block after Containment:
Runtime directory
path: /home/dev/.murmur/sessions/<session-id>/runtime
commands: read only (landlock)
On a host or workdir that cannot protect it, commands: reads can change it — <reason>, and the
same reason appears under Not protected here. The landlock+seccomp tier at Landlock ABI 1 or 2
reports this too, naming truncate(2). --json carries the object as runtime_dir, and
trace.jsonl's session_start carries it verbatim as effective_grants.runtime_dir. mur doctor
prints the same block for the project's declared containment.
Records directory
Every record the runtime writes about a session, and every file it writes for the session outside the runtime directory, lives in a per-session directory outside the work folder, kept after the session ends:
~/.murmur/records/ 0700
└── <workdir root without its leading />/ 0700, each level
├── evr_<32 hex>.json 0600, one per mur eval run whose baseline ran here
└── <session-id>/ 0700, created at staging, kept after the session
├── trace.jsonl
├── blobs/<sha256>
├── logs/bootstrap.log | otel.log | hook-<hook-name>.log
├── logs/shell-<timestamp>.log | <work-id>.log | check.log
├── completion.json
├── hooks/
├── out/result.txt | result_<task-id>.txt | compaction-summaries.jsonl
├── tmp/ 0700, created at staging
├── home/ 0700, created at staging
└── children/<capsule>-<16 hex>/ 0700
The workdir root names the group a session's directory sits in:
| Launch | Workdir root | Records directory |
|---|---|---|
mur run --workdir /home/dev/proj |
/home/dev/proj |
~/.murmur/records/home/dev/proj/<session-id> |
mur run from /home/dev/proj |
/home/dev/proj/workdir |
~/.murmur/records/home/dev/proj/workdir/<session-id> |
| A formation peer | ~/.murmur/formations/<frm_id>/<member> |
That directory mirrored under ~/.murmur/records |
| A delegated child | <parent records directory>/children/<capsule>-<16 hex> |
That directory mirrored under ~/.murmur/records |
The root is made absolute and resolved through its nearest existing ancestor, so a link above it names the directory it points at. A project moved to another path starts a new group; the old one stays under the old path.
| Path | Holds | Written by |
|---|---|---|
trace.jsonl |
One JSON object per session event. See Observability schemas | The runtime |
blobs/<sha256> |
Trace bodies too large to inline, addressed by their own digest. See Trace blobs | The runtime |
logs/bootstrap.log |
Staging and agent-loop diagnostics: the installed tool inventory, once per session and again each time it changes, compaction decisions, non-fatal write failures, and any line the runtime could not write to a closed standard output or standard error | The runtime |
logs/otel.log |
OpenTelemetry exporter diagnostics | The runtime |
logs/hook-<hook-name>.log |
Errors from one hook, one per line. <hook-name> is the bare name: hook-grafana for murmur/hook-grafana |
The runtime |
logs/shell-<timestamp>.log |
Full stdout and stderr of one shell command, written when either stream exceeds 16 KiB. The tool result names it by its absolute path | The runtime |
logs/<work-id>.log |
Output of a command moved to the background, written as the command runs, with an Exit code: line appended at its exit. background-command and its completion name it by its absolute path |
The runtime |
logs/check.log |
Stdout then stderr of the case's check, written by mur eval run after the session ended, 0600 |
mur eval run |
completion.json |
How a delegated child ended. Read by the launcher that started it | The child at the end of its session, and that launcher |
hooks/ |
The current directory of every hook declared capabilities.filesystem.records: true. murmur/hook-eval writes hooks/eval.jsonl and hooks/logs/hook-hook-eval.log here. Created only when such a hook is declared |
Those hooks |
out/result.txt |
The agent's final output. Written on every terminal outcome of a task that ran; a failure writes error: <message>, a turn stopped at inference.max_tokens writes the model's partial text followed by a truncation marker naming that field and the cap, and a task that ends canceled writes canceled: the task was canceled before it completed, so it has no result. A task canceled before it started writes nothing |
The runtime |
out/result_<task-id>.txt |
Per-task copy of the final output, so one task does not overwrite another's: a completed task's answer, or the canceled: line above for a canceled one. Only under lifecycle.conversation: threaded |
The runtime |
out/compaction-summaries.jsonl |
The text each committed compaction replaced the context with. See Compaction summaries | The runtime |
tmp/ |
The session's temporary folder: /tmp, $TMPDIR, $TMP and $TEMP to the session's shell commands, plan shell steps and native tools. The runtime writes nothing in it |
The capsule's commands |
home/ |
The session's home folder: $HOME to every shell command, plan shell step and native tool, and the home directory of the synthetic passwd entry under sealed. The runtime writes nothing in it |
The capsule's commands |
children/<capsule>-<16 hex>/ |
One directory per delegated child launch, each the child's --workdir. <capsule> is the bare capsule name; the 16 hex characters are fresh per launch |
The runtime creates it; the child capsule writes in it |
tmp/ and home/ count with the work folder against
capabilities.resources.workdir_max_bytes.
Beside the session directories, a group holds one eval-run record,
evr_<32 hex>.json, for each mur eval run whose baseline capsule's workdir root it is.
trace.retain and mur trace rm remove only
ses_ directories, so a record outlives the sessions it names and still reads the same.
How the capsule reaches it
| Reader | Where |
|---|---|
A shell command, a plan's shell step, a native tool, a transport: process harness |
$MURMUR_RECORDS_DIR, the absolute path of the session's records directory. Set by the runtime last, on $MURMUR_RUNTIME_DIR's terms |
A shell command, a plan's shell step, a native tool |
tmp/, read-write, as $TMPDIR, $TMP and $TEMP, and as /tmp where the session maps it. See The session's temporary folder |
A shell command, a plan's shell step, a native tool |
home/, read-write, as $HOME |
A hook declared capabilities.filesystem.records: true |
hooks/, as its current directory, read-write |
| A WASM tool, a driver, any other hook | Nothing |
Every reader given $MURMUR_RECORDS_DIR can open a shell log, out/result.txt or a delegated
child's work folder by its absolute path, read-only. A WASM tool cannot.
What protects it
The runtime directory's mechanisms, applied to the records directory — see
What protects it. A command that tries to overwrite, append to, truncate,
remove, rename over or create a file there gets Permission denied under Landlock and
Read-only file system inside a composed root; a hard link from it into the work folder gets
Invalid cross-device link. out/ and children/ are read-only to the capsule's commands like
every other record.
tmp/ and home/ are the two writable paths, each granted the work folder's rights:
| Mechanism | tmp/ |
home/ |
|---|---|---|
Landlock, on mountns+pivot_root+landlock+seccomp and landlock+seccomp |
A rule with the work folder's rights: Execute only with capabilities.filesystem.workdir_exec |
The same rule |
The composed root, on mountns+pivot_root+landlock+seccomp (sealed) |
A verified read-write bind at /tmp |
A verified read-write bind at its own absolute path, mounted after the records directory's read-only bind |
A mount-namespace /tmp mapping |
Bound over /tmp |
Bound back read-write at its own path when it lies under the host's /tmp |
No record can be appended to, replaced, moved or hard-linked into either, and neither can be renamed. The runtime's own writes go on for the whole session. On a host with no kernel containment, macOS included, a command can change the whole records directory.
A records directory inside the work folder is judged as a runtime directory inside it is — see A runtime directory inside the work folder.
Lifetime
| When | What happens |
|---|---|
| Staging, after every refusal that stops a launch | The directory is created, with tmp/ and home/ |
| The session ends | Nothing: the directory is kept, with out/, home/, tmp/ and children/ |
A later launch of a capsule declaring trace.retain |
Each session that policy does not keep loses its records directory. Without --workdir its runtime-created work folder, <manifest-dir>/workdir/<session-id>, goes with it; a --workdir folder is never touched. A read-only directory a command left in tmp/ or home/ does not stop the removal |
mur trace rm |
The same removal, for one ended session the operator names |
An ended formation pruned by its entry member's trace.retain |
The formation's whole records subtree goes with its formation directory |
Reading it
mur trace, mur eval, mur run --resume, mur ps and mur stop read the records directory. A
--workdir given to mur trace or mur eval names the workdir root — the directory a run was
given as --workdir, or <manifest-dir>/workdir — and the sessions listed are the ones in its
group. mur run --json's readiness line and the
running record carry the session's records_dir.
Reporting
mur run --explain-scope prints a Records directory block after Runtime directory:
Records directory
path: /home/dev/.murmur/records/home/dev/proj/workdir/<session-id>
commands: read only (landlock)
Its four keys, its per-tier commands: reasons and its Not protected here statement are the
runtime directory's. --json carries the object as records_dir, trace.jsonl's session_start
carries it as effective_grants.records_dir, and mur doctor prints the same block. A
Temporary folder block follows it in both; see Reporting.
Links inside the work folder
The runtime never follows a symbolic link inside the work folder, the records directory or the
runtime directory. Every path in the tables above, and every file the runtime reads to decide what
to do next, is reached one directory at a time from its directory, and a link anywhere on the way
is refused.
Nothing outside the work folder is written, read or removed through one. The directory passed to
--workdir may itself be reached through a link; only what lies beneath it is checked.
| The runtime refuses | When it would |
|---|---|
| A symbolic link at any directory on the way to a path, or at the path itself | Write, append to, read, create, or remove it |
| A FIFO, socket, device node or directory where a regular file belongs | Append to or read it |
| A regular file with a second hard link | Append to it, or rewrite it in place |
Under --workdir, keep task.md and input.txt in that directory as real files: a link at the
task file fails the first task.
What a refusal does depends on when it happens:
| When | What happens |
|---|---|
Staging: installing tools and skills, MURMUR.md |
The launch fails with E-RUN-050, naming the path and the link |
| During a task, on any transport | A workdir_path_refused trace line for each refusal, then task_failed with cause workdir_path_refused, then task_end with exit_status: failed. No further request is sent to the model for that task. An A2A task ends failed with the refusal as its status message, and mur run --task exits with E-RUN-050 |
A tool's own write — a submitted plan, fetch-peer-file, manage.pull(), pull-artifact, manage.remove() or remove-artifact |
The tool call returns an error naming the path, and the task then fails as above |
| Between tasks | The next task to start fails as above before it reads its task or sends a request |
Durable state store
An artifact that declares capabilities.state is granted one directory outside the work folder and
the records directory:
| Path | Lifetime | |
|---|---|---|
| Work folder | --workdir, otherwise <manifest-dir>/workdir/<session-id> |
One session, unless --workdir names a directory you keep |
| Records directory | ~/.murmur/records/<workdir root>/<session-id>/ |
Until trace.retain or mur trace rm removes it |
| State store | ~/.murmur/state/<store>/ |
Every session of that capsule, on that machine |
A WASM component sees the store as a directory named state, beside the work folder it sees as its
current directory. Its code reaches the store with an ordinary relative path:
std::fs::write("state/notes.jsonl", contents)?; // the store
std::fs::write("out/result.txt", summary)?; // the work folder
A native tool runs with the work folder as its current
directory and reads the store's absolute path from MURMUR_STATE_DIR.
~/.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 work folder, and only the artifact that declared it can reach it. A
subtree of the work folder would be readable by anything that can reach the work folder — murmur/tool-read 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.
The artifact that declared it reaches the store; shell commands and sub-capsules do not. Under
the sealed containment class the store is absent from the composed root of every shell command,
so neither a binary allowlisted by capabilities.shell.allow nor a sub-capsule allowlisted by
capabilities.spawn.allow can open it. A native tool that declared the store reaches it on every
tier, and on the tiers with no filesystem scope so does every other subprocess — see
A native tool's own config and store.
What belongs in the work folder instead
Per-project notes. The work folder 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.
Six 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. It holds the turn's thinking and text only: a tool call in a capped turn is never run and never kept |
fence |
A message whose content is wrapped in the untrusted fence — the source name, tool:<artifact name> or task:<origin> |
inserted_by |
A message a hook output put into the conversation: replace-context for a compaction hook's replacement, seed-context for an on-task-start hook's seed |
produced_by |
Every assistant message the agent loop writes, when the capsule declares inference.alternates: {"driver": "<artifact>", "model": "<model>"}, the driver choice that produced it. A message without it counts as the primary's |
inserted_by is set by the runtime when it commits the hook's output, whatever role the message has and whatever the hook claimed. A message a compaction hook hands back unchanged keeps the value it already had, so a person's task message stays without one.
An image a tool returned, or an A2A message carried, is one image block after the message's text
block, holding a reference rather than the image:
{"type":"image","source":{"type":"file","sha256":"9f2c…","media_type":"image/png","bytes":48213}}
The bytes are in blobs/<sha256> beside conversation.jsonl, written once however many messages
name them, in a directory only the owner can read. A conversation that keeps no record stores them
in the session's records directory blobs/ instead. The driver receives the
newest context.images.keep images as image bytes and each older one
as a line naming it:
| The driver receives | When |
|---|---|
| The image's bytes | It is one of the newest context.images.keep images in the conversation |
[image not shown: <media type>, <n> bytes, sha256 <hex>; the <keep> newer images in this conversation are shown instead] |
It is older |
[image unavailable: <media type>, <n> bytes, sha256 <hex>] |
Its blob is missing or holds another number of bytes |
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, each
user message the runtime adds to continue the task — a reopen's feedback, handed-off work's
outcomes, the keep-working message, a failing
done check's output, and the
statement of a capped turn — 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: its records directory, with its trace.jsonl, blobs/, out/, home/, tmp/ and children/, and, for a launch without --workdir, its work folder <manifest-dir>/workdir/<session-id> |
context.retain.max_messages |
The oldest message lines of one conversation.jsonl, then every blobs/<sha256> no surviving line's image block names. The file, the directory, every surviving id and every image a kept line names stay |
context.retain.max_age |
A whole <context-id>/ directory under the record store |
mur trace rm |
One ended session, named by the operator: its records directory, its work folder when the runtime created it under ./workdir, and a running-capsule record left by a process that is gone. A --workdir folder and the conversation record stay |
The three policies prune at launch, in the runtime, immediately after the session's
session_start, and every deletion is written to that session's own trace as a
retention event. mur trace rm removes when it is run and
writes no trace.
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.
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 |
harness-session.json |
0600 |
Each mode is set again on every append and every rewrite.
The harness session map
On inference.transport: process the harness owns the
conversation. The runtime keeps no message list for that capsule and records one thing per context
— the opaque session id the harness answers to — in the directory the record would have been in:
~/.murmur/conversations/<record>/<context-id>/harness-session.json
<record> and <context-id> are the two segments above, so
context.record_store points both transports at the same place.
{"type":"murmur.harness-session","session_id":"0199c7d4-1f60-7c31-9a6e-0f2b9c1d4e55","harness":"claude-code","driver":"murmur/driver-claude-code","created_ms":1758326400000,"updated_ms":1758326461000}
| Key | Value |
|---|---|
type |
Always murmur.harness-session. A file carrying anything else is read as no entry |
session_id |
What the harness calls this conversation. Opaque: the runtime only ever hands it back |
harness |
The harness its process driver named, for a person reading the directory |
driver |
The full <scope>/<name> of the process driver artifact that drove it, for a person reading the directory |
created_ms |
When this context first got a session |
updated_ms |
When the entry was last written |
The runtime never compares harness or driver against the capsule it is launching. Whether a
session still exists is a question only the harness can answer, and it answers it by failing the
turn — see E-RUN-036.
What decides the launch
lifecycle.conversation |
The map holds an id for this context | The harness is launched |
|---|---|---|
stateless |
Not read | Starting a new conversation, under a fresh id |
threaded, or any launch under mur run --resume |
No | Starting a new conversation, under a fresh id |
threaded, or any launch under mur run --resume |
Yes | Continuing that conversation, under the stored id |
A task whose context id resolved to nothing starts a new conversation and writes no entry.
The id the runtime mints is a bare UUID. A harness that mints its own reports it back, and that is what the context is keyed on from then on. A turn that never names a session is keyed on the id it was handed.
What writes an entry
The entry is written once, when the run ends, and only when the run produced observable work: a
piece of text, a thought, a tool call, or a turn that ran to completion. A harness names its
session before the model has produced anything, so a run that got no further — one interrupted in
that window, or one refused for auth or quota — leaves no entry, and the next task in that
context starts a conversation the harness will answer to.
A cancel is not special. A task stopped after the harness has said something keeps the conversation it opened, and the next task in that context continues it.
What removes an entry
One action, asked for on the request that starts the next turn:
| Surface | How it is asked for |
|---|---|
| CLI | mur run --forget-session, with --context <id> |
| A2A door | forgetSession: true in the metadata of a SendMessage or SendStreamingMessage message that starts a task |
The entry is deleted from the file and from the running capsule's memory, the run's trace records a
harness_session_forgotten event naming the
context, the id that was dropped and who asked, and the turn launches as a new conversation under
the same context id. Asked for a context that holds no entry, it drops nothing and records nothing.
Nothing else removes one. A failed resume leaves the entry where it is — see
E-RUN-036 — and context.retain prunes
nothing here.
What it does not keep
context.record is inert on this transport, exactly as
context.record: off is everywhere:
Under transport: process |
|
|---|---|
conversation.jsonl |
Never written |
murmur:conversation/read |
Reads an empty page, however the hook is granted |
An on-task-start seed-context |
Rejected, recorded as a context_seed with reason unsupported_transport |
context.retain |
Prunes nothing — it is wired to the conversation record, which this transport keeps none of, and a deleted session id is a person's conversation lost with no way back |
mur conversation ls and rm |
Neither lists nor removes these contexts: both work on conversation records, and rm answers E-CNV-001. mur run --forget-session is what drops a context's session |
The session id is the whole of what murmur keeps for such a capsule. To read or export the conversation itself, use whatever the harness offers for its own sessions.
context.record: off and a host whose HOME cannot be resolved both mean no file at all: the
capsule still threads the contexts it serves for as long as it is running, and forgets them when it
stops. A map that cannot be written is reported once to stderr and to logs/bootstrap.log, and the
task runs on.
Compaction summaries
out/compaction-summaries.jsonl in the session's records directory. 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 work folder. It pins every
registry-resolved artifact to a version and a hash, and records who wrote the pin:
lock_version: 3
artifacts:
- name: ana/some-tool
resolved_version: "1.2.3"
sha256:
any: "<sha256>"
origin: runtime
session: ses_0190a1b2c3d4...
- name: local/some-native-tool
resolved_version: "0.4.2"
sha256:
platforms:
darwin-aarch64: "<sha256>"
linux-x86_64: "<sha256>"
origin: operator
lock_version must be 3. Each entry's name is the artifact's full name, <scope>/<name> —
see Artifact names. An entry may carry owner_id,
GitHub's numeric id of the scope's owner, written only when known: by mur install from a source,
and by a runtime pull whose source or stored payload records one.
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. An artifact installed from a file is pinned as local/<name>. Skipped for -g (no project directory) and for --all-platforms, which installs into the global store |
manage.pull(), pull-artifact |
The same verify-then-upsert, from a running capsule rather than the CLI. Pins a new entry as origin: runtime and sets owner_id when the source or the stored payload records one; a pull whose owner id differs from one the lock records for the scope is refused — see Pin origin |
manage.remove(), remove-artifact |
Deletes one origin: runtime entry the manifest does not declare, and rewrites every other entry unchanged — see Pin origin |
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.
Pin origin
| Key | Values | Written | Notes |
|---|---|---|---|
origin |
operator, runtime |
Always | operator: written by mur install, or by the mur run or mur eval that created the lock. runtime: written by a running capsule through manage.pull() or pull-artifact. An entry with no origin key reads as operator |
session |
A session id (ses_…) |
Only for origin: runtime |
The session whose capsule pulled the artifact. Required and non-blank for runtime; refused on operator |
Each writer sets the origin like this:
| Writer | New entry | Existing entry |
|---|---|---|
mur run / mur eval creating the lock |
origin: operator |
— (they never rewrite an existing lock) |
mur install |
origin: operator |
Becomes origin: operator and session is dropped; mur install prints one line for each runtime pin it adopts |
manage.pull(), pull-artifact |
origin: runtime with the pulling session |
Origin left exactly as it was |
manage.remove(), remove-artifact |
— | An origin: runtime entry is deleted, whichever session pulled it. An origin: operator entry is never deleted |
A pull never turns an operator pin into a runtime one, and never relabels an earlier pull with its own session.
manage.remove() reads the entry's origin when it runs, so an
entry mur install adopted mid-session is no longer removable. It also refuses an entry for an
artifact murmur.yaml declares, whatever its origin, because the next mur run needs that entry.
mur run and mur eval stage only the artifacts murmur.yaml declares, so an entry a capsule
pulled that the manifest does not declare is never staged. At the next launch, a declared artifact
whose pin has origin: runtime is handled by role:
| Declared as | At launch |
|---|---|
A tool or skill without gateway: |
Staged, and marked untrusted wherever it reaches the model: its tool-array description opens with the runtime-origin marker, and a skill's guidance arrives fenced. See Artifact origin |
runtime: hook, runtime: driver, or with gateway: |
Refused with E-RUN-043 |
A skill named by inference.system_prompt_artifact |
Refused with E-RUN-043 |
Both hold on every launch until mur install <scope>/<name>@<version> adopts the pin as operator.
session_start.runtime_artifacts in trace.jsonl
lists every runtime pin a launch staged. mur doctor reports every runtime
pin.
A mur that predates origin reads a lock that has it, and drops both keys if it rewrites the
file; every entry then reads as operator.
A capsule with write access to the project directory — one run with the project as its
--workdir — can edit murmur.lock like any other file there, origin included. List
murmur.lock in capabilities.filesystem.read_only to have the
runtime refuse the writes to it that it can identify.
A lock of any other lock_version, or an entry whose name is not a full name, is refused before
any entry is checked, with a message ending "delete murmur.lock and run mur install to regenerate
it". 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, an
unsupported lock_version, or a malformed origin / session pair fails the run with
E-RUN-003. An install whose registry hash disagrees with the pin fails with E-REG-005. See Diagnostics.