The artifacts model: a conversation's deliverables are DERIVED from what the product witnessed, never declared by the agent (lane artifacts-model, as-built)
Chinese original: docs/design-artifacts.zh.md (this is its twin).
- The owner's two rulings: (1) never depend on teaching the agent a tool — adoption forks the deliverables; (2) every deliverable is STORED and SHOWN one way.
- The real shape (a read-only study of a fleet user's 16 transcripts): the agent writes articles / briefs / reports as
.mdunder the conversation's cwd with claudeWrite, thenEdits them; it names the file in its text only ~40 % of the time; 0 SendUserFile; half of all Writes are config / skills / code; the user reports her own edits by hand. - So: the witness is the harness's WRITE RECORD, the noise is code, and the user's edit must reach the owning conversation without her typing it.
{key: host+':'+path, host, path, name, kind, firstAt, lastAt, by: agent|user, writes, edits, lastOp, bytes, lastId}.
kindis closed: doc (markdown / text / rst / docx / pdf / csv) · page (html) · design · media (image / audio / video) · upload · code (source / config, and agent-steering markdown: CLAUDE.md / SKILL.md / AGENTS.md /.claude/…) · other (never a throw). en/zh/ja words in KIND_WORDS.- THE reducer
apply / fold: a Write births or re-births, an Edit bumps; the same path twice = one row; the same call twice (lastId— the parse and the device feed both see it) moves it once; a relative path is made absolute against the cwd; an Edit on a path never written here is still a row (by: agent). - Bounds: ≤ 500 rows per conversation, the oldest code rows evicted first, every eviction returned and logged.
view(rows)= the chip's order: doc › page › design › media › upload › other, newest change first, code folded behind a count.
Every descriptor declares artifactsOf(record) → [{path, op: 'write'|'edit', bytes, id}] | [] (the readers: src/harnesses/artifacts-of.js):
- claude: an assistant record's tool_use Write (write) / Edit / MultiEdit / NotebookEdit (edit),
file_path/notebook_path. - codex: apply_patch — the custom_tool_call envelope (
*** Add File:= write,*** Update File:= edit) or the live function_call's JSONchanges[](add / update); a FileChange item is the RESULT of the same call, never counted twice. - ACP (opencode, …): a tool call's
diffcontent (no oldText = a new file) and fs/write_text_file. - shell:
null= never produces. test-harness-contract proves each; nothing downstream branches on an id (§78's harness family does not rise).
- The rows live on the live session (
session._artifacts, a session-schema row, owner stdout), persist in session-metaartifacts(debounced 1.5 s like taskRecords) and are restored at both boot-restore sites. - ONE writer, src/server/artifact-registry.js:
observe(session, record), called by every harness's stdout consumer (claude's parse AND the device feed's claudeSideEffects make the same call; codex-events; acp-events); it asks the session's descriptor hook. - Rebuild: normalizers.convertWithCards'
opts.artifacts(artifactDeriveOpts) derives the rows over the SAME records and places their cards (the hook has no afterRecord: the previous record folds before the next one); rebuildHistory merges with the persisted rows (the user's saves live only there) and every card says the merged counts. A history-only view (a dead session's transcript) derives them at the read. A restart loses nothing; a rebuilt chat shows the same cards.
- The card: ONE per deliverable row (a system message,
noticeKind: 'artifact', id{view id}:af:{hash of the key}), placed at its own time (a live birth at the tail; a rebuild's card inside the loaded slab, or nowhere when the slab starts after it — the chip lists it), PATCHED IN PLACE on every later write / edit (aneditop with content only, never re-created): "BRIEF.md · Changed 3 times · 2min ago". One click opens it in its kind's viewer beside the chat (from— the Cmd+click door, no Cmd). Code rows get no card. - The Artifacts chip: a keyed chip
Artifacts · Nin the chat status bar (GET /api/artifacts = the server'sview(rows)— the whole conversation, not the loaded slab); its popover lists documents first, code folded behind "Code (n)". - The user's own saves: an editor window opened from a chat (openFile keeps
fromWin) sends ONE wsartifact-touch {sessionId, host, path, summary}on save; the owning conversation's row getsby: user, edits+1, its card is patched, and the agent hears ONE next-turn note "[Doc edit] : +a −b lines" (the stash — free, never a billed wake). - The setting
artifacts.autoOpenDocs(Chat, default ON): when the agent WRITES a new doc row while the chat is on screen it opens beside the chat; an Edit never re-opens; OFF = the card only.
ownerOf({host, path}) → {sessionId, row} | null— the newest conversation whose registry holds the path.noteEdit({sessionId, host, path, summary})— ONE next-turn note to that conversation through the stash: "[Doc edit] :". Both live in src/server/artifact-registry.js; their PURE halves in src/artifacts.js (ownerOfIn, editNoteText, lineDelta). The Doc window (lane doc-window, wired in lane artifacts-e2e — the stub deleted) opens .mdand calls both: a save on a registry-owned row istouch(by: user, edits+1, the card patched, the note), afrom-owned file gets the note alone; the note goes through the peer-text belt (the summary names the file's section headings). Other files' raw code editor save still sendsartifact-touchwith "+a −b lines".
The three stores that already listed a conversation's things feed the ONE reducer — never a second list; the registry is a VIEW over them and each KEEPS its store and its own surface (the Pages list still reads published-pages.json, the Design window's Home still reads designs.json).
- page — src/server/published-pages.js
onPublished(the ONE notify point: publish, re-publish, flags, unpublish) ⇒ server.js hands it toartifact-registry.notePage⇒ apagerow on the SOURCE file (keyhost:srcPath: a page published twice, or written by the agent and then published, is ONE row) carryingurl= the /p/ link andstatepublished | unpublished — an unpublish never deletes the row. The host is the srcKey's prefix only when the key IS<host>:<srcPath>. - design — src/server/design-engine.js
onDesign(its notify: registration, re-open, rename) ⇒noteDesign⇒ adesignrow per (conversation, folder), named by the registry's title. - upload — the composer's attachments: chat-input →
uploadFilesBatched({sessionId})→ POST /api/upload's form names the chat ⇒noteUploads⇒ oneuploadrow by: user per landed file (a file-explorer upload names no chat and feeds nothing). - The reducer (src/artifacts.js): REG_OPS publish / unpublish / open / upload birth or name a row and never count as a write or an edit; KIND_RANK design › page › upload › an extension's kind (a published design stays a design; an uploaded file the agent edits stays an upload).
pageOp/designOp/uploadOp/storeRowsare the PURE store → op translators. - Replay — a rebuild merges THREE inputs: the transcript's derivation ∪ the persisted rows ∪
storeRows(normalizers' store-rows seam, set by artifact-registry.configure: this conversation's pages + designs read back from their stores). The uploads have no other store: the persisted rows ARE the composer's attachment record. The merge keeps the later fact (a persisted unpublish over an older store record; a re-publish after it) and the higher-ranked kind. - The card opens its kind's door through ONE function (ChatView._openArtifact — the chip's row and the card share it): a published page its /p/ link (app.openBrowser), a design the Design window (app.openDesign with this chat), everything else (an unpublished page, an upload) the file beside the chat. The meta line says Published / Unpublished / Attached by you.
- Not done: the view-only history of a dead conversation (ws-handler's read) derives the transcript rows only — no store rows there.
The owner looked for the latest page of a conversation whose site is SERVED by its own Background Work job — no published page, so the chip showed code only. A service row = a job this conversation OWNS (the owner-conversation lineage) that LISTENS on a TCP port:
- The facts — src/proc-listen.js reads the job's pid tree's listening port (/proc/net/tcp ∩ socket inodes; async, bounded, no exec) on the jobs engine's own 5 s sweep, ≤ once per job per 30 s;
job.listen{port, firstAt, at} persists. - The row — DERIVED at every read (src/artifacts.js
serviceRows; the registry'sservicesOf), never stored: {kind: service, jobId, name, port, url http://:/, since, state running | stopped, stoppedAt}; a stopped job's row stays 24 h greyed, then goes. - The card — the jobs door (
_serviceDoor→noteService) places it at the first listen and patches it in place; a click opens the Web view; ⋯ = copy URL / open in a new tab / show the job. The chip lists Services under their own head after the documents.
- Subagents — a parent record that ENDS a Task / workflow agent (its tool_result, a task-notification) names the agent's own transcript (
helperTranscriptsOf, src/harnesses/helper-transcripts.js); its Write / Edit records go through the SAMEartifactsOfand become the PARENT's rows withvia: {kind: subagent, name}— card at the Task's end, the rebuild the same (replay parity). Before: 0 rows live, 0 at a rebuild (measured on a fixture). - Hand-over —
vibespace-msg send <agent> "…" --artifact <path>…(≤ 20; the message's reach; the helper's OWN rows only; same machine — never copied): the receiver's rowvia: {kind: handover, from}+ its card "Handed over by …"; the helper's rowhandedTo. A design re-registers under the receiver and its Design window opens;vibespace-design open --for <conversation>= the same door.
Direction A built whole (E1–E9): the chip's popover is bounded to the room above the chip and scrolls under a sticky top bar — the filter (name · path tail · helper; / focuses, Esc clears then closes), group ▾ (by kind · helper · day) and sort ▾ (recently changed · name · times changed) remembered on this device, the count line ("43 artifacts · 174 code files"; "7 matches · 2 code" while filtering); the Recent band (5 newest of any kind, code included; hidden while filtering or under 8 rows); collapsible group heads (Code collapsed by default); rows "icon · name (the match marked) · kind · helper · changed N times · time · ⋯", keyed and patched in place. The ⋯: open beside · show in Files · copy path · open a hand-over's helper conversation; "open the helper's record" is absent (a subagent row names its helper but carries no transcript door). Phone: a bottom sheet. Direction B built behind the button (the owner: 「A思路可以,可以加上个按钮可以打开B」 — the desk's "not now" reversed): the popover's ⤢ opens the Artifacts window — a rail (all · kinds · helpers · recent, with counts) and a table (name · kind · helper · edits · last change · path) sortable by its column heads, the same filter, the same ⋯ — over the SAME PURE model (src/lib/artifacts-list-model.js), live with the conversation. C (filter only) not built.