MINARA

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

FileRoleLifecycle
SOUL.mdIdentity, voice, boundariesLong-lived; edits trigger a one-time disclosure to the user
AGENTS.mdSession startup rules, safety defaultsLong-lived
IDENTITY.mdDisplay name, emoji, vibe (UI surface)Long-lived
USER.mdOperator profile (timezone, focus, risk, watchlist)Long-lived
MEMORY.mdCurated facts / preferences / decisionsLong-lived; the dreaming task appends ## Dreamed YYYY-MM-DD sections (or quarantines the source logs if the LLM output trips the PII filter)
BOOTSTRAP.mdTwo-stage first-run onboarding playbookArchived to .done.<timestamp> after the agent emits BOOTSTRAP_DONE
TOOLS.mdEnvironment-specific tool / skill notesLong-lived; operator-curated
HEARTBEAT.mdBetween-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>.mdPer-session daily journalAppended 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:

  1. Direct on disk — open them in any editor while the agent is between sessions. The next session reads what you saved.
  2. 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.
  3. 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 PUT carries 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>.md is appended every WORKSPACE_DAILY_LOG_INTERVAL turns by the daily-log writer. Per-session files avoid append races between the REPL and the HTTP gateway. Both the legacy aggregate YYYY-MM-DD.md and the new per-session shape are picked up by the loader on the next session.
  • Long-term — curated. MEMORY.md has three subsections — ## Facts, ## Preferences, ## Decisions — that the operator curates. The dreaming task proposes additions as ## Dreamed YYYY-MM-DD sections (append-only). When the LLM output trips the PII filter, the offending source logs are quarantined instead and MEMORY.md is left untouched (see PII quarantine).
  • Between-session memo — heartbeat. HEARTBEAT.md carries last_seen, session_id, surface, turn_count, recent open_loops, and the user-owned ## Schedule. The writer preserves the ## Schedule section verbatim across writes (schedule_raw round-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:

  1. Acquire <workspace>/.dreaming.lock (O_EXCL). If the lock is held by a live peer, skip this tick.
  2. Read state. Load the mtime watermark from .dreamed-state.json and the offending-filename set from .dreamed-quarantine.json.
  3. Select daily logs newer than the watermark, not in the quarantine set, within the windowDays window. Pick a contiguous newest-first suffix that fits WORKSPACE_DREAM_TOTAL_INPUT_BYTES (default 256 KB). Each log is tail-truncated to 64 KB before joining the prompt.
  4. Read the existing MEMORY.md (also tail-truncated to 64 KB) so the LLM can dedupe against what is already promoted.
  5. Call the LLM with the curator prompt: extract durable signal vs noise; do not duplicate existing facts; do not include anything that looks sensitive.
  6. 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.
  7. PII gate. Run the response against the secret-pattern list. On a hit, append the offending log filenames to .dreamed-quarantine.json, log at error level, and exit. The watermark is NOT advanced (see PII quarantine).
  8. Append a ## Dreamed YYYY-MM-DD section to MEMORY.md (append-only; operator edits above are never touched).
  9. 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.

FilePurposeLifecycle
.dreaming.lockMulti-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.jsonWatermark last_processed_mtime.Advanced after a successful append OR a NOTHING_TO_PROMOTE response. NOT advanced on PII hit.
.dreamed-quarantine.jsonArray 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 hostname matches the local machine and kill -0 pid reports 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 maxFileBytes in 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):

ClassPattern shape
anthropic_keysk-ant-... (matched before openai_key)
openai_keysk-... (20+ alphanumerics)
stripe_keysk_live_..., rk_live_...
github_patghp_, github_pat_, gho_, ghu_, ghs_, ghr_
google_api_keyAIza + 35 url-safe chars
slack_tokenxoxb-, xoxp-, xoxa-, xoxr-, xoxs-
aws_access_keyAKIA + 16 uppercase / digits
evm_address0x + 40 hex
btc_bech32bc1...
btc_legacybase58 starting with 1 or 3
pem_private-----BEGIN ... PRIVATE KEY----- blocks

On a hit:

  1. The response is not appended to MEMORY.md.
  2. The offending source filenames are appended to .dreamed-quarantine.json with the matched pattern name.
  3. A structured error-level log line is emitted with the pattern, filenames, and workspace path.
  4. 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.json for last_run_at. Tail the agent log for module: "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 return NOTHING_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_skip and exits clean.
  • "PII quarantine fired." Inspect .dreamed-quarantine.json for 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_MS above 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 to memory/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:

  1. Turn 1 — agent reads BOOTSTRAP.md, asks the operator the onboarding questions (name, timezone, focus, risk tolerance, default venue). Does NOT emit BOOTSTRAP_DONE.
  2. Turn 2 — operator answers; agent uses write_file to populate USER.md, briefly confirms what was written, then ends the reply with the literal token BOOTSTRAP_DONE on 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:

  1. identity block (cached) — SOUL.md + AGENTS.md.
  2. memorySnapshot (dynamic, leads the dynamic block) — USER.md, MEMORY.md, HEARTBEAT.md, recent daily journal entries.
  3. bootstrapInstructions (dynamic, only when BOOTSTRAP.md exists) — first-run playbook.
  4. 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:

TypeId prefixdata payloadBuilder
chartx-{ charts: [...] } ECharts optionschart-builder.ts
spreadsheetx-{ csvContent, title?, description? }CSV, materialized .csv
reportr-full HTML / markdown reportDeep 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/workspace on launch.
  • Set MINARA_WORKSPACE_DIR=~/.openclaw/workspace in the environment.
  • Run the OpenClawWorkspaceImporter to 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.

On this page