Skip to content

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


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.