Concepts

Artifacts

Persistent server-side file storage scoped to (tenant, conversation). Replaces the file-on-disk model with a sandboxed blob store that lives in the same SQLite database as conversations and structured memory — no host filesystem access, no path-traversal attack surface, automatic cleanup when a conversation is deleted.

The default /write slash command stays ephemeral — content is streamed as an SSE file event the frontend renders, with no server-side persistence. Adding --persist saves the same content into the artifact store. Two slash commands let agents read it back: /read <path> and /list.

Storage model

PropertyValue
Backing storeSQLite BLOB column on tenant_artifacts
Primary key(tenant_id, conversation_id, path) unique
ConcurrencySerialised by SQLITE_OPEN_FULLMUTEX (see Operational notes)
Cascade deleteFK ON DELETE CASCADE on tenants(id) and conversations(id) — drop a conversation, its artifacts go with it

Path safety

Paths are validated by a single canonical sanitize_artifact_path helper used by every entry point. Rules:

RejectedReason
Empty path or > 256 charslength
Component > 128 charslength
Absolute (/foo, leading \)path traversal protection
Drive letter (C:\, anything with :)Windows safety
.., .path traversal
Hidden (.env, .git, any .foo)accidental dotfile leakage
Null bytes or control chars (< 0x20 or 0x7f)injection / display safety
Windows-reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9, case-insensitive, with or without extension)cross-platform safety

Backslashes are normalised to forward slashes before validation; repeated separators collapse; trailing slash is dropped. The canonical form is what goes into the unique index.

Quotas

Hard ceilings, enforced inside put_artifact for every entry point:

ScopeDefault
Per file1 MB
Per conversation50 MB
Per tenant500 MB

PUT-on-conflict semantics: writing to an existing path replaces the row (same id, bumped updated_at), and quota math subtracts the existing size before checking the cap. Overwriting a 100 KB file with 200 KB only "costs" 100 KB against the conversation quota.

Responses surface the post-write totals in tenant_used_bytes and conversation_used_bytes so callers (and the agent's own tool result) know how close to the cap they are.

Agent slash commands

CommandEffect
/write <path>Ephemeral SSE file event only. The default; matches the prior behaviour exactly.
/write --persist <path>SSE event AND artifact-store row. Returns OK: persisted N bytes (artifact #ID, K of LIMIT bytes used) so the agent can self-throttle on quota.
/read <path>Reads a previously persisted artifact in this conversation. ERR if path is invalid or not present.
/read #<aid>Same-conversation read by artifact id.
/read #<aid> via=mem:<entry_id>Cross-conversation read, capability granted by a memory entry that links the artifact. See "Memory ↔ artifact" below.
/listLists this conversation's artifacts, one per line: <path> (<size> bytes, mime=<type>, id=<id>).

The --persist write goes through the same path validator as the HTTP endpoint — the agent can't smuggle in .. or absolute paths even if it tries. CLI/REPL contexts (no conversation, no tenant) leave the artifact callbacks null; /write --persist falls back to ephemeral with a WARN: --persist requested but artifact store is unavailable line, and /read//list return ERR.

Memory ↔ artifact linkage

Memory entries can carry an optional artifact_id field referencing a row in the artifact store. The link is set via:

  • HTTP: POST/v1/memory/entries or PATCH/v1/memory/entries/:id with artifact_id in the body. Cross-tenant ids return 400.
  • Agent slash: /mem add entry <type> <title> --artifact #<id> — the agent that just /write --persist-ed a file can file it directly into a memory entry in the same turn.

When the linked artifact is deleted (directly via DELETE/v1/artifacts/:aid, or indirectly via its conversation being deleted), the database trigger memory_entries_artifact_id_clear nullifies the link automatically. The memory entry survives; future reads see artifact_id: null (or (link expired ...) in the agent-facing format).

The cross-conversation read rule

Memories are tenant-scoped; artifacts are conversation-scoped. When an agent reads a memory whose linked artifact lives in a different conversation, fetching the blob requires explicit citation of the memory entry as a capability:

/read #<aid>                     # SAME-conversation: allowed unconditionally
/read <path>                     # SAME-conversation by path: same as today
/read #<aid> via=mem:<entry_id>  # CROSS-conversation: required citation form

Server-side, the artifact reader validates that:

  1. The artifact exists for this tenant.
  2. If the artifact's home conversation matches the active conversation, the read is allowed.
  3. Otherwise, via=mem:<entry_id> MUST resolve to a memory entry in this tenant whose artifact_id equals the requested id. Any visible entry can be referenced this way.

A missing or mismatched via= clause returns ERR. The agent never sees a cross-conversation artifact unless a memory entry grants access. Same-conversation reads (the common path) need no citation.

The /mem entry <id> agent response prints the literal /read line to copy:

[/mem entry 42]
#42 [reference] Findings report
linked artifact:
  #88  output/report.md  (1832 bytes, mime=text/markdown)
  cross-conversationfetch with: /read #88 via=mem:42
[END MEMORY]

Frontend safety

The path string lands on the client as a UTF-8 display field — it's untrusted. The frontend must NOT pass it directly to fs.writeFile or any other path-sensitive API without its own client-side sanitizer (same rules as the server's, plus your platform's specifics). If you let the user save the artifact to disk, build the destination path from the basename and a vetted directory of your choosing — never from the agent-supplied path.

Scope

  • Storage is backed by SQLite BLOBs in the same database as conversations and memory.
  • Versioning is PUT-on-conflict only — writing to an existing path replaces the row.
  • Artifacts never cross tenant or conversation boundaries except via the explicit memory-citation read path described above.
  • MIME types are agent-declared, not sniffed. Frontends should validate the type before rendering inline.

See also