Skip to content

Workspace File API

Read and write an agent's workspace identity files via the API. The gateway's file watcher recomposes CLAUDE.md on disk after a write.

Allowed filenames: SOUL.md, USER.md, MEMORY.md, AGENTS.md, HEARTBEAT.md, IDENTITY.md

How a write reaches running sessions (frozen-at-spawn). CLAUDE.md is read by a session only at spawn — a live process never re-reads it, so a change applies on the session's next spawn. The watcher therefore never SIGKILLs a live session just to push a change; it recomposes CLAUDE.md and tiers the restart by change class:

Changed file(s)Effect on running sessions
MEMORY.md, USER.md (memory)No session is restarted. The change applies on each session's next natural spawn. A memory write can never drop a live session.
SOUL.md, AGENTS.md, IDENTITY.md (identity)Busy sessions are skipped; idle sessions are deferred (respawn on their next message) — never SIGKILLed mid-idle.
HEARTBEAT.md / otherNormal restart-or-defer (idle sessions restart now).

Memory budget (gateway.memory). Self-authored memory files (MEMORY.md, USER.md) that exceed a soft char budget get a loud, actionable over-budget banner prepended to their CLAUDE.md section at compose time — instead of a silent [TRUNCATED] — so the agent consolidates on its next spawn (frozen-at-spawn, no restart). The soft budget sits well under the hard per-file limit (still applied as a context safety net); the banner is the primary signal for memory files. Config (global, injected by migration at configVersion 1.0.19):

FieldDefaultMeaning
memoryBudgetChars8000Soft budget for MEMORY.md (0 = disabled).
userBudgetChars3000Soft budget for USER.md (0 = disabled).
overBudget"warn"Banner severity: "warn" (⚠️) or "error" (🛑, stronger wording). An unknown value falls back to "warn".
writeRoutingfalse (template: true)planning-65: inject the two-tier write contract (durable → MEMORY.md/USER.md; episodic task-log → memory/<topic>.md) into the Memory Rule and let dreaming route episodic ops out. false = kill-switch (exact pre-routing behavior).
episodicArchiveDir"memory"Workspace-relative dir episodic notes land under (slug-validated + realpath-confined).

A memory file under its budget composes cleanly (no banner). Non-memory files are unaffected. A hard-reject memory tool (refuse an over-budget write) is a planned follow-up; v1 is the compose banner only.

Write routing (gateway.memory.writeRouting, planning-65). With routing on, the Memory Rule tells the agent that MEMORY.md (injected every prompt) holds only durable facts and that episodic task-log belongs in memory/<topic>.md (searchable via memory_search, not in-prompt). The nightly dreaming reviewer may then emit tier:"episodic" add ops, which the applier appends to memory/<topic>.md (slug ^[a-z0-9-]{1,64}$, realpath-confined under the workspace; a memory-only change ⇒ no session restart). A one-shot CLI drains an existing over-budget file: node dist/agent/dreaming/migrate-cli.js <workspaceDir> [--apply] runs the deterministic terminal sweep (compactor) plus a gated episodic route-out (propose writes .dreaming/migration-plan.md; --apply moves). Pinned sections (## User/## Feedback/## Preferences) are never moved; relocated content stays searchable (recall preserved). planning-67 (gateway.dreaming.autoRouteOut, default true): the nightly dream runs this route-out automatically whenever MEMORY.md is over budget (after the compactor, before the reviewer — transcript-independent, idempotent, pinned-excluded, unbounded by maxChangesPerRun), so no per-agent CLI is needed; and an over-budget net-shrink remove relocates its block to memory/archive/pruned.md (searchable) before cutting it — closing the prior hole where a remove deleted without archiving. No dream op deletes recall; all writes are memory-only ⇒ no session restart.

Nightly dreaming (gateway.dreaming). A nightly background pass consolidates memory: a print-only claude -p reviewer (no tools, no --dangerously-skip-permissions) reads a lookback window of the agent's own session transcripts and proposes memory-consolidation ops. In auto mode (the default, planning-64 K4) the ops are applied to MEMORY.md/USER.md through a safe applier: a rollback pre-image is written to .dreaming/backups/ first (retention-capped) and only when a mutation actually commits; a replace/remove anchor is applied by index splice (so $ in the new text is literal) and is skipped when ambiguous (the target occurs more than once); a bounded-loss guard measures gross original content deleted (an add cannot mask a large delete) — capped at 25% under budget, raised for deletions when the file is over budget (removals are the shrink lever) but never a near-total wipe; over budget, an add (or a replace that would grow the file) is skipped (net-negative); and just before the atomic rename the on-disk content is re-checked against the snapshot (CAS) so a concurrent live-agent edit is never clobbered. Writing memory is a memory-only change ⇒ no session restart (Part A). In propose mode (set mode: "propose" to opt back into dry-run) proposals are written only to DREAMS.md + JSONL audit under <workspace>/.dreaming/ — no memory file is mutated. When the shared KB is auto, only the adds the applier actually wrote are promoted to the shared vault (never a locally-skipped proposal), and only when the content carries a real fact — an add whose content is nothing but MEMORY.md index-pointer bullets is skipped, because those links resolve only inside the promoting agent's own workspace (issue #398). A durable proposal may also carry an optional topic kebab-slug naming the fact; it becomes the shared note's name when present. Runs on a nightly scheduler (dreamHour/dreamMinute/dreamTimezone), skips when a session was active within quietMinutes, and is a no-op when enabled:false or maxChangesPerRun:0. dreamMinute (0–59, default 0) pairs with dreamHour for minute-level scheduling — set it with staggerWindowMinutes: 0 to fire at an exact HH:MM (e.g. for a controlled re-test). planning-68: to avoid a dreamHour:00 thundering-herd across agents (every agent's timer fires at the same instant, each spawning a route-out + reviewer claude -p), a deterministic per-agent jitter (a stable hash of agentId in [0, staggerWindowMinutes*60s), not Math.random) is added to each agent's scheduled delay, spreading runs across the window; staggerWindowMinutes defaults to 30, is clamped [0,55] (so the offset never crosses the next hour), and 0 disables it (exact prior behavior). See the README gateway.dreaming reference for all fields.

Archive staleness GC (gateway.dreaming.staleness). A deterministic nightly pass (planning-66) that runs next to the compactor in auto mode to keep the Lane-2 archive's search quality high — it is a search-quality fix, not a prompt-budget one (planning-65 already moved task-log off the injected prompt). It soft-invalidates archive entries and never deletes them: a superseded entry (a deterministic supersedes/replaces/obsoletes #N match — which finally populates the previously-inert supersedes_key) or an aged-out entry (idle-since-last-retrieval past staleTtlDays and retrieved fewer than minRetrievalKeep times) is moved to memory/archive/stale.md with an invalid_at stamp, staying under memory/ so it remains indexed and memory_search-able. An entry retrieved after invalidation is promoted back to the active archive (recall feedback). Recall is fed by an append-only read-path log (kb_retrieval_log), written fire-and-forget by the Bun read tool through a dedicated writable handle (never read-modify-writing chunk rows) and gated by recordRetrievals; the GC folds it into each entry's last_retrieved. Identity is a reindex-surviving content hash (entry_hash) stored in a standalone kb_entry_lifecycle table (not a cascade child of kb_chunks), so age survives re-chunking. Indexing writes those rows for every source it sees, including files the content-hash guard skips as unchanged — a source indexed before the lifecycle table existed is backfilled from its stored mtime, so its real age is preserved rather than restarted (issue #398; without this the GC could only ever consider files edited after the feature shipped). keepImportance and pinned files (memory/pinned/**) are never aged out; evergreen Lane-1 (MEMORY.md/USER.md) is structurally excluded. Every move is CAS-guarded with a timestamped backup and drops no live session (memory-only, Part A). The read-path recorder is gated to the MCP layer via GATEWAY_RECORD_RETRIEVALS. Fields: enabled (default true; false ⇒ GC no-ops), staleTtlDays (default 90), keepImportance (default 7), minRetrievalKeep (default 1), supersession (default true), recordRetrievals (default true), maxInvalidationsPerRun (default 50) — a ceiling on how many entries ONE run may soft-invalidate, oldest-idle first, with the remainder resuming on later runs. Aging is wall-clock driven, so without it the first run after anything that widens the GC's visibility (such as the lifecycle backfill above) would relocate every already-expired entry in a single night. Restores are never capped. Per-agent override under agents[].dreaming.staleness. Injected by migration at configVersion 1.0.26.

Knowledge archive (gateway.knowledge.archive). Two-lane memory (planning-64). A per-agent SQLite/FTS5 index (agents/<id>/kb.sqlite, built on node:sqlite — zero new dependency) over the agent's memory/*.md notes plus the evergreen MEMORY.md/USER.md, so an agent can retrieve on demand what does not fit in the always-injected core. Injected by migration at configVersion 1.0.21. Indexing is hash-guarded (unchanged files skipped), tags every chunk with fail-closed provenance (owner/agent/untrusted/system; unclassified ⇒ untrusted), and prunes only genuinely-absent files (a transient read error never drops a live source). The index is refreshed by a detached subprocess at agent-session spawn, so all synchronous SQLite work runs off the gateway event loop. Retrieval is exposed to the agent as two read-only MCP tools — memory_search (keyword/FTS5 over the archive → ranked snippets with file+line range, provenance, importance) and memory_get (bounded exact excerpt of a memory-scoped file by line range; path-traversal-guarded). The MCP layer runs under Bun, so those tools read kb.sqlite via bun:sqlite. Fields: enabled (default true; false ⇒ complete no-op, no DB created), tokenizer (FTS5 tokenizer, default "unicode61"; "trigram" for CJK/Thai), chunkTokens (default 400), chunkOverlap (default 80, clamped below chunkTokens). Per-agent override under agents[].knowledge. Core-shrink: when the archive is enabled and MEMORY.md is over its soft budget, compose injects a compact auto-generated section index (headings + a brief per top-level section) plus a pointer to memory_search, instead of the banner + truncated full text — so the bulk never enters the prompt. The index is derived deterministically at compose time (no LLM); the on-disk MEMORY.md is never modified, and its full content stays searchable via the archive. Falls back to the banner + full text when the archive is disabled or the file has no headings to index. Retrieval note: whenever the archive is enabled, a short platform-level --- MEMORY RETRIEVAL --- section is injected into every agent's system prompt (independent of the over-budget shrink path), so the memory_search/memory_get tools are discoverable at all times rather than only when the shrink pointer appears — a capability shared by all agents, injected once here instead of duplicated into each agent's AGENTS.md.

Shared KB (gateway.knowledge.shared). A cross-agent knowledge base (planning-64 K3): a shared SQLite/FTS5 vault outside any single agent's workspace (<root>/<project>/kb.sqlite, default root ~/.claude-gateway/shared/kb). The gateway has no built-in project concept, so sharing is keyed by an explicit project value — agents with the same project share one vault; project defaults to "global" (all agents share by default). Notes dropped under the vault's notes/ dir are indexed (provenance agent — the owner's own trust domain) and reachable via memory_search with corpus:"shared" (the shared vault) or corpus:"all" (this agent's memory + shared, merged by relevance). Concurrent writers are safe without an in-process lock: note files are written temp-then-atomic-rename, and the shared index is guarded by PRAGMA busy_timeout across processes. The gateway passes the resolved vault dir to the MCP layer via GATEWAY_SHARED_KB_DIR. Fields: enabled (default true), project (default "global"; validated to one safe path segment), root (default ~/.claude-gateway/shared/kb), mode (propose|auto, default auto — governs per-agent→shared promotion of durable dreamed facts), graph (default false). Per-agent override under agents[].knowledge.shared. On-demand read/write: the memory_shared_create/memory_shared_get/memory_shared_update/memory_shared_delete MCP tools let any agent create, read, update, and delete notes in the vault immediately (works in either mode, since these are explicit calls rather than automatic promotion). A note's identity is a freeform name the caller picks — there is no agent-id prefix and no per-agent ownership; any agent can create, edit, or delete any note. memory_shared_create fails on an exact-name collision (use memory_shared_update instead), and before writing also searches the shared KB for content-similar notes, returning them instead of creating unless called with confirm:true — this keeps agents from independently creating near-duplicate notes under different names; when confirm:true proceeds past a near-dup nudge, each related note found is linked into the new note's content as a [[wikilink]] rather than left as a disconnected duplicate. memory_shared_update fails if the name doesn't exist yet (use memory_shared_create instead), and replaces the note's full content; if the new content would drop 50%+ of the existing note's lines, it returns a warning instead of writing, requiring confirm:true to proceed — a guard against blindly clobbering a note instead of editing it. memory_shared_get returns a note's full current content (memory_search only returns short snippets). Nightly promotion shares this same freeform namespace (issues #386, #398): a promoted fact is named after its dream proposal's topic slug when the reviewer supplied one, else its reason, so the same recurring fact maps to the same note across dream nights and lands as an update, not a new file. A fallback name that reads as an editing instruction rather than the name of a fact (an imperative edit verb plus a positional/file anchor, e.g. "insert after the cron section") is passed over, since no future occurrence of that fact could ever match such a name — the note is named from the fact itself instead, and the promotion is abandoned only when nothing nameable remains. Every declined promotion is logged with its reason — including the two that used to be silent: a merge the note-size cap refuses, and an unexpected write failure (which still never fails the local dream). A name that doesn't collide is still checked against a Node-native near-duplicate search (a node:sqlite port of the same OR-match FTS query the tools above use under Bun), but that query is recall only: an unattended merge additionally requires real token containment between the promoted fact and the candidate note. Below that bar the fact is written as its own note — two notes are a recoverable tidy-up, two unrelated facts fused into one are not. [[wikilink]]s to related notes clear a lower bar than merges, because a link is an additive claim while a merge destroys the distinction between the notes; they are attached whether the fact merges or lands as a new note, so a fact below the merge bar never becomes a disconnected graph node. Containment is measured against each candidate's full note body rather than the FTS chunk that matched — though against a capped seed: the query carries at most SEED_TERM_BUDGET significant tokens, so the bar reads as "half of the fact's leading topic words are already in this note", not half of the entire fact. Retired stale__* notes are excluded as merge targets, and a recurrence under a retired name folds the retired body back into the live note and deletes the twin — on both the create and the update path, since either leaves a live file at that name and the GC's own restore refuses from then on. A retired note stays indexed and searchable by design, so a twin left beside a live note of the same name would answer every query twice, permanently. The twin is dropped only once the merged write actually lands; a write the size cap refuses leaves it in place and logs why. Folding also prevents two files sharing one entry_hash. All four tools trigger an immediate reindex on write/delete, needing GATEWAY_NODE_EXEC_PATH (the gateway's own Node execPath, forwarded alongside GATEWAY_SHARED_KB_DIR) since the Bun MCP process cannot itself run the node:sqlite-based reindex CLI.

Graph layer (gateway.knowledge.shared.graph). Opt-in memory-wiki-style compile (planning-64 K5) over the shared vault, run after each shared reindex (off the event loop). Deterministic — no LLM. Reads note YAML frontmatter (title, claims[] with id/text/status/confidence, page confidence, updatedAt) and body [[wiki-links]], and writes <vault>/reports/: relationship-graph.md (every link edge), backlinks.md (reverse edges), contradictions.md (claims sharing an id whose text/status diverges), stale-pages.md (≥ 90 days old), low-confidence.md (confidence < 0.5). The generated reports dir is never scanned as source.

GET /api/v1/agents/:agentId/files/:filename

Read a workspace file. Returns empty content if the file does not exist yet (not a 404).

bash
curl -H "X-Api-Key: my-secret-key-123" \
  http://localhost:10850/api/v1/agents/alfred/files/SOUL.md | jq
json
{ "filename": "SOUL.md", "content": "# Soul\n\nAlfred is warm, helpful, and precise." }

PUT /api/v1/agents/:agentId/files/:filename

Write a workspace file. Requires write access to the agent. Max 1MB.

Request body:

FieldRequiredDescription
contentYesFull file content as a string
bash
curl -X PUT \
  -H "X-Api-Key: admin-key-456" \
  -H "Content-Type: application/json" \
  -d '{"content": "# Soul\n\nAlfred is warm, helpful, and precise."}' \
  http://localhost:10850/api/v1/agents/alfred/files/SOUL.md | jq
json
{ "filename": "SOUL.md", "message": "File saved. CLAUDE.md will auto-reload." }

Error responses:

StatusWhen
400Filename not in allowed list, invalid format, or content not a string
400Content exceeds 1MB
403Key has no write access to agent
404Agent not found

Claude Code inside.