Workspace
Markdown-backed agent identity and memory, plus the artifact and file stores for durable conversation content
Minara stores its identity, persona, curated memory, and between-session
state as plain markdown files in a workspace directory (default
~/.minara/workspace/).
The workspace is the agent's human-curated ground truth. When an
inferred user preference, a learned methodology, or a scenario
playbook conflicts with what MEMORY.md / SOUL.md / AGENTS.md
say, the workspace wins. The agent loop assembles the system prompt
in that order — see agent loop for
how the dynamic and cached blocks compose.
The on-disk layout is OpenClaw-compatible: file names, sections,
and frontmatter match OpenClaw's
AGENTS.default
schema, so a workspace authored for either tool is portable to the
other (see OpenClaw compatibility below).
File set
| File | Role | Lifecycle |
|---|---|---|
SOUL.md | Identity, voice, boundaries | Long-lived; edits trigger a one-time disclosure to the user |
AGENTS.md | Session startup rules, safety defaults | Long-lived |
IDENTITY.md | Display name, emoji, vibe (UI surface) | Long-lived |
USER.md | Operator profile (timezone, focus, risk, watchlist) | Long-lived |
MEMORY.md | Curated facts / preferences / decisions | Long-lived; the dreaming task appends ## Dreamed YYYY-MM-DD sections (or quarantines the source logs if the LLM output trips the PII filter) |
BOOTSTRAP.md | Two-stage first-run onboarding playbook | Archived to .done.<timestamp> after the agent emits BOOTSTRAP_DONE |
TOOLS.md | Environment-specific tool / skill notes | Long-lived; operator-curated |
HEARTBEAT.md | Between-session memo (last_seen, open loops, schedule) | Rewritten by the agent at every turn end (state sections); user owns ## Schedule |
memory/YYYY-MM-DD-<session>.md | Per-session daily journal | Appended every N turns; consolidated by the dreaming task |
HEARTBEAT.md is the only file the agent rewrites every turn. The
others are operator-edited (or proposed-only by the dreaming task).
Auto-seed on boot
createApp() calls seedWorkspaceIfMissing(workspaceDir) before
loading the workspace, copying any missing template from
apps/agent/src/workspace/templates/ into the runtime directory. The seed is
idempotent: a file the operator has edited (or one that previously
existed) is never overwritten. New installs get the full file set
without manually running minara setup.
HEARTBEAT.md is intentionally NOT in the seedable set — the agent
writes a fresh one at the end of its first turn, and seeding stale
state would lie about session continuity. The template file ships in
apps/agent/src/workspace/templates/HEARTBEAT.md only as a Web UI
restore-template source and a schema reference.
Editing the workspace
Three surfaces edit workspace files:
- Direct on disk — open them in any editor while the agent is between sessions. The next session reads what you saved.
- REPL slash commands —
/soul,/identity,/heartbeat, and/workspace [soul|agents|identity|user|memory|heartbeat|bootstrap]print the live file. The slash commands reference covers the full set. - Web UI Settings → Workspace — picks any file from the
whitelist, edits it in a textarea, and saves manually. Saves go
through the gateway with sha256 optimistic concurrency: every
PUTcarries the sha256 the editor loaded with, and a 409 from the gateway opens an "overwrite or reload" modal so concurrent edits across browser tabs / sessions can never silently clobber each other.
The Web UI editor never touches the filesystem directly — every
operation routes through /v1/workspace/files* (see the
HTTP API reference).
Three layers of memory
The workspace cooperates with the SQLite memory store (see memory system) along three horizons:
- Short-term — daily journal.
memory/YYYY-MM-DD-<session>.mdis appended everyWORKSPACE_DAILY_LOG_INTERVALturns by the daily-log writer. Per-session files avoid append races between the REPL and the HTTP gateway. Both the legacy aggregateYYYY-MM-DD.mdand the new per-session shape are picked up by the loader on the next session. - Long-term — curated.
MEMORY.mdhas three subsections —## Facts,## Preferences,## Decisions— that the operator curates. The dreaming task proposes additions as## Dreamed YYYY-MM-DDsections (append-only). When the LLM output trips the PII filter, the offending source logs are quarantined instead andMEMORY.mdis left untouched (see PII quarantine). - Between-session memo — heartbeat.
HEARTBEAT.mdcarrieslast_seen,session_id,surface,turn_count, recentopen_loops, and the user-owned## Schedule. The writer preserves the## Schedulesection verbatim across writes (schedule_rawround-trip), so operator comments, blank-line groupings, and unknown fields all survive.
Dreaming consolidation
Dreaming is the agent's overnight librarian. When
WORKSPACE_DREAM_ENABLED=1, a setInterval task wakes every
WORKSPACE_DREAM_INTERVAL_HOURS (default 24 h), feeds the recent
daily logs plus the current MEMORY.md to the active LLM, and
appends the durable extracts as a ## Dreamed YYYY-MM-DD section
at the end of MEMORY.md. Off by default. Costs LLM calls; turn
on for long-running deployments where the operator wants
short-term journals to evolve into curated long-term memory
without manual review.
Pipeline
Every tick runs through these gates in order. Any gate that returns early skips the rest:
- Acquire
<workspace>/.dreaming.lock(O_EXCL). If the lock is held by a live peer, skip this tick. - Read state. Load the mtime watermark from
.dreamed-state.jsonand the offending-filename set from.dreamed-quarantine.json. - Select daily logs newer than the watermark, not in the
quarantine set, within the
windowDayswindow. Pick a contiguous newest-first suffix that fitsWORKSPACE_DREAM_TOTAL_INPUT_BYTES(default 256 KB). Each log is tail-truncated to 64 KB before joining the prompt. - Read the existing
MEMORY.md(also tail-truncated to 64 KB) so the LLM can dedupe against what is already promoted. - Call the LLM with the curator prompt: extract durable signal vs noise; do not duplicate existing facts; do not include anything that looks sensitive.
- If the response (after trim) is exactly
NOTHING_TO_PROMOTE, advance the watermark and exit. The same logs will not be re-fed to the LLM next tick. - PII gate. Run the response against the secret-pattern
list. On a hit, append the offending log filenames to
.dreamed-quarantine.json, log aterrorlevel, and exit. The watermark is NOT advanced (see PII quarantine). - Append a
## Dreamed YYYY-MM-DDsection toMEMORY.md(append-only; operator edits above are never touched). - Advance the watermark and release the lock.
The whole pipeline is wrapped in try/finally so every exit
path (including LLM error and PII quarantine) releases the lock.
State files
Three sibling files in the workspace root carry the dreaming task's state. All three are JSON; all three are safe to inspect or edit by hand while the agent is between ticks.
| File | Purpose | Lifecycle |
|---|---|---|
.dreaming.lock | Multi-process mutex. Carries pid, hostname, ISO started_at, opaque token. | Created on lock acquire, removed on release. Stale by WORKSPACE_DREAM_LOCK_TTL_MS or by dead pid (same host). |
.dreamed-state.json | Watermark last_processed_mtime. | Advanced after a successful append OR a NOTHING_TO_PROMOTE response. NOT advanced on PII hit. |
.dreamed-quarantine.json | Array of { quarantined_at, pattern, log_filenames, max_mtime } entries. | Appended by the PII gate. Operator removes entries (or deletes the file) to retry. |
Multi-process safety
The REPL (npm run dev) and HTTP gateway (npm run serve) each
boot a scheduler against the same workspace. Without
serialization their tick windows can overlap and double-promote
the same daily logs into MEMORY.md. The .dreaming.lock file
prevents this: every dream pass holds the lock for the duration
of the LLM call and releases it on every exit path.
Two reclamation paths handle a crashed peer:
- Age-stale. If
Date.now() - started_at > WORKSPACE_DREAM_LOCK_TTL_MS(default 30 minutes, decoupled from the run interval), the lock is reclaimable. - PID-stale (same host only). If the lock's
hostnamematches the local machine andkill -0 pidreports the process is gone, the lock is reclaimable.
Stale takeover re-stats the lockfile and re-reads the token before unlinking, so a fresh lock written by another process between "decide stale" and "unlink" is detected and yielded to (closes the read-then-unlink TOCTOU race).
NFS is not supported: O_EXCL semantics are not guaranteed
atomic across all NFS clients. Keep dreaming enabled on a single
host for any workspace mounted on shared storage.
Budget and selection
A long window of dense daily logs would otherwise overflow the model's context. Two caps protect against that:
- Per-file. Each daily log is tail-truncated to 64 KB
(configurable via
maxFileBytesin the dreaming task's deps). Daily logs are append-only, so the tail (most recent turns) is exactly what the curator prompt cares about; the previous head-truncation discarded the freshest content. Truncated files are prefixed with[...truncated head]so the LLM knows the slice is partial. - Total.
WORKSPACE_DREAM_TOTAL_INPUT_BYTES(default 256 KB) caps the combined byte size across all selected logs. Selection is a contiguous newest-first suffix: the algorithm walks candidates from newest to oldest, accumulating bytes, and stops when the next file would push the total over budget. The most recent log is always kept (truncated to 64 KB if it alone exceeds the total). The LLM never sees a "day 1 plus day 30 with nothing between" hole.
MEMORY.md is also tail-truncated to 64 KB when fed to the LLM
for in-prompt dedupe, since the most recent ## Dreamed sections
(the ones most likely to be re-proposed) live at the end.
NOTHING_TO_PROMOTE strictness
When the curator prompt judges nothing in the logs worth
promoting, it returns the literal token NOTHING_TO_PROMOTE on
its own line. The dreaming task accepts only an exact match
(after trim): a response of ### Facts\n- discussed the NOTHING_TO_PROMOTE marker behaviour is appended normally,
because it is legitimate output that happens to mention the
marker by name. The previous substring check would have silently
swallowed any such response.
PII quarantine
A regex post-filter runs against the LLM's response before the
append. Matched patterns are high-confidence secrets that should
never land in MEMORY.md (which is fed back into the system
prompt of every future session, so a leak compounds):
| Class | Pattern shape |
|---|---|
anthropic_key | sk-ant-... (matched before openai_key) |
openai_key | sk-... (20+ alphanumerics) |
stripe_key | sk_live_..., rk_live_... |
github_pat | ghp_, github_pat_, gho_, ghu_, ghs_, ghr_ |
google_api_key | AIza + 35 url-safe chars |
slack_token | xoxb-, xoxp-, xoxa-, xoxr-, xoxs- |
aws_access_key | AKIA + 16 uppercase / digits |
evm_address | 0x + 40 hex |
btc_bech32 | bc1... |
btc_legacy | base58 starting with 1 or 3 |
pem_private | -----BEGIN ... PRIVATE KEY----- blocks |
On a hit:
- The response is not appended to
MEMORY.md. - The offending source filenames are appended to
.dreamed-quarantine.jsonwith the matched pattern name. - A structured
error-level log line is emitted with the pattern, filenames, and workspace path. - The mtime watermark is not advanced. Bumping it would silently archive a security event; instead the offending filenames are filtered out of future selections (by name) until the operator clears them.
Solana base58 (32–44 chars), BIP-39 mnemonics, and bare 64-hex strings are deliberately omitted from the pattern list. Their false-positive rate against legitimate hashes, transaction signatures, and prose is too high to use as a hard gate. The prompt-level instruction ("do not include anything that looks sensitive") still asks the LLM to suppress those classes.
To clear a quarantine entry, edit
<workspace>/.dreamed-quarantine.json and remove the entry, or
delete the whole file. The next tick will reconsider those daily
logs. If the leak is in the source log itself, edit the offending
memory/YYYY-MM-DD-<session>.md first.
Operator workflow
Common situations and where to look:
- "Did dream run last night?" Check
.dreamed-state.jsonforlast_run_at. Tail the agent log formodule: "workspace/dreaming-task"lines:appended/nothing_to_promote/pii_detected_quarantine/lock_held_skip/llm_error. - "Why didn't this fact get promoted?" Was the source
log's mtime newer than
last_processed_mtime? Was the source filename in.dreamed-quarantine.json? Did the LLM returnNOTHING_TO_PROMOTE? The structured log lines name the reason. - "Two processes ran dream at the same time and I see nothing
doubled." That is the lockfile working. The losing process
logs
lock_held_skipand exits clean. - "PII quarantine fired." Inspect
.dreamed-quarantine.jsonfor the pattern name and source filenames. Open the source log and decide: redact and clear, or delete the entry (if the match was a false positive on prose) and let the next tick retry. - "My LLM call routinely takes 45 minutes." Raise
WORKSPACE_DREAM_LOCK_TTL_MSabove your worst-case wall clock so a healthy in-flight dream is not declared stale by a peer process.
Configuration
Five env vars govern dreaming behavior. The full reference with defaults and effects is in env vars: workspace:
WORKSPACE_DREAM_ENABLED, master switch (default OFF).WORKSPACE_DREAM_INTERVAL_HOURS, tick cadence (default 24).WORKSPACE_DREAM_TOTAL_INPUT_BYTES, global byte budget for the prompt input (default 256 KB).WORKSPACE_DREAM_LOCK_TTL_MS, lockfile TTL for stale takeover (default 30 minutes).WORKSPACE_DAILY_LOG_INTERVAL, how often the agent appends tomemory/YYYY-MM-DD-<session>.md(the input dreaming consolidates).
SOUL.md change disclosure
The runtime hashes SOUL.md at boot and persists the result in
<workspaceDir>/.soul-state.json. When the hash changes between
sessions, the agent loop receives a one-time prompt-level notice
asking it to acknowledge the change to the operator on its next
reply. After that single acknowledgment, the change is not
mentioned again — the detector re-baselines on every read.
BOOTSTRAP.md two-stage onboarding
A fresh workspace ships with BOOTSTRAP.md. The flow is:
- Turn 1 — agent reads
BOOTSTRAP.md, asks the operator the onboarding questions (name, timezone, focus, risk tolerance, default venue). Does NOT emitBOOTSTRAP_DONE. - Turn 2 — operator answers; agent uses
write_fileto populateUSER.md, briefly confirms what was written, then ends the reply with the literal tokenBOOTSTRAP_DONEon its own line.
The bootstrap-handler hook (turn-end) detects the token and renames
BOOTSTRAP.md to BOOTSTRAP.md.done.<timestamp> (audit trail
preserved). On the next turn the file is gone, so the dynamic
prompt block drops the bootstrap instructions automatically.
Workspace as ground truth (precedence)
The agent loop assembles the system prompt with workspace md FIRST, ahead of derived layers:
identityblock (cached) —SOUL.md+AGENTS.md.memorySnapshot(dynamic, leads the dynamic block) —USER.md,MEMORY.md,HEARTBEAT.md, recent daily journal entries.bootstrapInstructions(dynamic, only whenBOOTSTRAP.mdexists) — first-run playbook.- Skill catalog, scenario playbook, personalization, methodology hints, and other derived layers come after.
The ordering is deliberate: when MEMORY.md says "user prefers
spot over perps for BTC" and the personalization layer infers
"user prefers perps", MEMORY.md wins. This precedence is the
contract enforced in apps/agent/src/core/prompt-builder.ts
and codified in AGENTS.md's Soul section.
Configuration
Defaults are tuned so a fresh install is useful out of the box:
- Heartbeat: ON (
WORKSPACE_HEARTBEAT_ENABLED=1) - Daily-log: OFF (
WORKSPACE_DAILY_LOG_ENABLED=0) - Dreaming: OFF (
WORKSPACE_DREAM_ENABLED=0)
The full list with defaults and effects is in the env vars reference.
Artifacts and files
The workspace markdown is durable state. Two sibling stores hold
durable content: artifacts (charts, spreadsheets, and reports the
agent produces during a turn) and files (binary uploads from the
user). Both sit alongside the workspace and the
sandbox, with a different contract
from each: the sandbox is scratch space the agent freely overwrites
during a turn, while artifacts and files must survive session
boundaries, carry a stable id, and never be mutated by a stray
write_file. They never share paths with the sandbox, so a tool writing
scratch output cannot clobber a stored chart or a user upload.
Artifact store
Artifacts live in the chat_artifacts SQLite table, owned by
artifacts/artifact-store.ts.
Three types share one row shape:
| Type | Id prefix | data payload | Builder |
|---|---|---|---|
chart | x- | { charts: [...] } ECharts options | chart-builder.ts |
spreadsheet | x- | { csvContent, title?, description? } | CSV, materialized .csv |
report | r- | full HTML / markdown report | Deep Research |
Every builder flips one status path:
insert() (running) → markCompleted(data) (completed) or
markError(msg) (error). The REPL renders (running) placeholders and
updates them in place; the audit log records every transition.
The model references finished artifacts by URI, never by guessing an id:
chart://x-…, report://r-…, spreadsheet://x-…. A hallucinated id
resolves to a structured ArtifactNotFoundError, which the model sees
and corrects. At turn end,
gateway/render/artifact-materializer.ts
scans the finalized message for those URIs and writes browsable files
(self-contained HTML viewer, raw JSON, and a 2x PNG per chart; a .csv
per spreadsheet) under $dataDir/artifacts/<id>/. PNG
rendering is best-effort: without the Playwright Chromium download the
materializer logs chart_png_skipped and continues.
File store
Files are opaque bytes the agent stores and references but does not
parse, owned by
files/file-store.ts.
The default LocalFileStore writes under $dataDir/uploads/, keyed by
chat/files/<userId>/<unix_ms>-<random_id>.<ext> (the unix_ms prefix
keeps uploads naturally time-sorted).
A StoredFile carries a key (the stable id written into the chat
message) and a url (what the LLM provider fetches for a multimodal
message). An upload (POST /v1/files) runs through fileStore.put(...),
which attaches the key to the pending message and enforces a default
20 MB limit matching the providers' multimodal caps.
files/message-translator.ts
maps an attachment key to each provider's preferred multimodal shape on
the way into the loop and back to a key on the way to storage. Message
text lives in the sessions row; binary content lives in the file store;
the translator bridges them. Nothing in sessions stores raw binary. An
uploaded spreadsheet is converted to structured text by
files/spreadsheet-parser.ts,
so the model can reason over its contents without a full attachment
round-trip.
Security posture
- Files are never executed. The store is a blob cache, no code path evals uploaded content.
- Keys and artifact ids are stable references, not secrets. Treat a URL
or
chart://id as a bearer capability; the gateway adds session-level auth on top. - Deletion is explicit.
LocalFileStore.delete(key)removes the file from disk; route GDPR-style purges through it so the removal is intentional and traceable.
For the HTTP endpoints that return these, see API → /files and the artifact fetch routes.
OpenClaw compatibility
The workspace format is byte-compatible with OpenClaw's
AGENTS.default
schema. Same file names, same section headings, same frontmatter
shape. A workspace authored for either tool is portable to the
other without translation; the only files that differ in spirit are
runtime-only artifacts each tool owns separately.
Three ways to bring an existing OpenClaw workspace into Minara:
- Pass
--workspace ~/.openclaw/workspaceon launch. - Set
MINARA_WORKSPACE_DIR=~/.openclaw/workspacein the environment. - Run the
OpenClawWorkspaceImporterto copy files into Minara's default location.
BOOTSTRAP.md and HEARTBEAT.md are deliberately skipped by the
importer — the first is a one-time onboarding playbook (Minara
ships its own seed), the second is rewritten by the agent every
turn end so a stale heartbeat would lie about session continuity.