---
type: Guide
title: Artifacts
description: Publish, version, protect, and view artifacts.
tags: [usage, artifacts, mcp, pwa]
status: draft
---

# Artifacts

An artifact is a titled document with an immutable version history, owned by a
project. Agents author artifacts over MCP; the human reads them in the PWA or
at a public URL. Artifacts are indexed for search alongside feed events and
session brains.

## Kinds

An artifact kind is `html` or `markdown`. `artifact_publish` carries the two in
its argument schema, so an agent reads them off `tools/list` or
`agent-hub tools` rather than guessing, and a kind that is neither is refused
with `invalid_argument` naming both.

- A `markdown` artifact is rendered to HTML by the hub. Raw HTML in the
  markdown source is escaped, so a published note cannot script or load
  anything.
- An `html` artifact is stored as authored and rendered inside a sandboxed
  frame with no same-origin access, so it cannot reach the hub or the admin
  token.
- A protected artifact of either kind has no server-side rendering: the server
  holds ciphertext only, and the viewer renders the decrypted source.

The blob is capped at 50 MiB.

## Publish and update

Publishing uses the MCP tools:

```
artifact_publish(project_id, title, kind, content, description?, label?, envelope?, idempotency_key?)
artifact_update(artifact_id, content, envelope?, base_version?, force?, label?, idempotency_key?)
artifact_get(artifact_id, version?)
artifact_versions(artifact_id)
artifact_list(project_id, session?)
artifact_delete(artifact_id)
```

`artifact_publish` returns an `artifact_id` and a `version`. `artifact_update`
publishes a new version of the same artifact and returns the new `version`; the
id never changes. Both accept an optional `idempotency_key`, so a retry after a
dropped connection returns the original result instead of a duplicate version.

A publish carries display metadata: a `description` (2000 characters at most),
and a `label` naming the version (60 bytes at
most). A blank title on a markdown artifact falls back to its first heading;
otherwise the title is required.

A publish records the caller session as its lineage when the agent acts within
an active session. Callers cannot supply or forge session lineage: the session
identifier is derived directly from the authenticated caller principal.
Updating an artifact preserves the original session lineage, as it preserves the
original `actor`; new versions do not move who created the document.
Similarly, publishing records the creator agent as `actor`, taken from the
resolved caller principal. Callers cannot forge `actor` in the publish request.
Updating an artifact preserves the original `actor`; new versions do not
overwrite who authored the document.

Concurrent updates are guarded by optimistic concurrency. Pass the version the
edit is based on as `base_version`: if the artifact has moved on, the update
is refused with a conflict naming the current version, and nothing is written.
Pass `force` to overwrite anyway. An update without `base_version` applies on
top of the current version, as before. A `label` on an update renames the new
version; without one the label is kept. An explicit null or empty string clears
the label.

## Reading

`artifact_get` returns the metadata and the current content, and accepts an
optional `version` to read one snapshot instead. For a protected artifact that
content is the ciphertext; decryption is the client's job and never the
server's. `artifact_versions` lists the immutable history oldest first, each
entry with its own title, description, label, and encryption state.
`artifact_list` lists a project's artifacts, most recently updated first,
optionally filtered by `session`. Everywhere an artifact is returned, it carries
the publishing `actor` (null on rows written before the field was added), and
`comments_count` (total comments across all versions) with `comments_open`
(unresolved comments), both defaulting to 0 when there are none.
`artifact_delete` removes an artifact, its history, and its index row, and
records a `deleted` event on the feed.

An unprotected artifact is served as a page at `/artifacts/<artifact_id>`,
with `?version=N` selecting a snapshot. The page is a small host shell
around a sandboxed frame: the shell owns the title, a light and dark theme
toggle, and a version picker when history exists, while the frame runs the
authored content with scripts allowed but no network, no storage, and no
same-origin access. The PWA embeds the same page. The REST routes serve the
same reads to the PWA:
`GET /api/v1/projects/:id/artifacts` (with optional `?session=ID`) and
`GET /api/v1/artifacts?session=ID` list artifacts, filtered by session when
specified;
`GET /api/v1/artifacts/:id` (with `?version=N`) returns the metadata and
content, including `comments_count` and `comments_open`, and for a public
markdown artifact includes a `rendered` HTML field;
`GET /api/v1/artifacts/:id/versions` returns the history;
`GET /api/v1/artifacts/:id/viewer-pass` returns the short-lived pass the PWA
embeds a shared artifact's page with (see Sharing);
`GET /api/v1/artifacts/:id/raw` returns the stored bytes as text, or a JSON
envelope with base64 ciphertext for a protected artifact. Deletion is
`DELETE /api/v1/artifacts/:id`. Every page carries link-preview tags with a
built-in preview card.

Markdown artifacts render in the page with full formatting: tables, code,
callout quotes (`> [!NOTE]`, `[!TIP]`, `[!WARNING]`, `[!CAUTION]`), and
mermaid diagrams, which run from a copy of the diagram runtime the hub
serves itself. Raw HTML in the markdown source is escaped. Authored HTML
runs inline scripts but cannot make external requests: inline all CSS and
JS, embed images and fonts as `data:` URIs, and keep no storage-backed
state.

## Protection

A protected artifact is encrypted in the client before upload. The server
stores only the ciphertext and an envelope (`{alg, kdf, iterations, salt, iv}`)
and never sees the plaintext. The viewer accepts an `iterations` count from
100000 to 10000000, and the client writes 600000. An envelope outside that
range, or one naming another algorithm, is refused before any password is
tried: the page says the artifact was encrypted with settings the viewer does
not accept, rather than reporting a wrong password, because no password would
open it. Opening the page shows a password gate with the ciphertext
fingerprint; the browser decrypts with the password and renders the result in
the same sandboxed frame.

On the artifact's own page the gate offers to remember the password for that
project on that device. It is kept as typed, in the browser's own storage, for
that origin: anything that could encrypt it would sit beside it. A remembered
password unlocks the next visit without asking, and the page then carries a
"Forget password" control that drops it and says so, after which the gate asks
again. A remembered password that no longer opens the artifact is dropped on
the spot. Opened from inside the app the artifact runs in a frame with no
origin of its own, where the browser refuses storage, so there the gate does
not offer to remember anything.

Protected artifacts have no version picker: switching
versions means reloading with `?version=N` and entering the password again.
Share the URL and the password through different channels.

### Sharing

An artifact is shared through the overflow menu in the artifact viewer. Sharing
is the operator's act: creating or revoking a link is admin-gated, done from
the PWA, and there is no MCP tool for it. An agent's artifact workflow is
publish, version and protect, and the human shares the result.

For a plain artifact, sharing issues a unique, unguessable capability token. The
resulting link (`/s/{token}`) serves the specific pinned version that was active
when the share link was created. Once a plain artifact has been shared, the
unauthenticated `/artifacts/{id}` route returns 404, so access is governed by the
token alone; a fresh share rotates the token. Revoking the link restores the
public page: the link is withdrawn, not the artifact, so the owner's own
address answers again and the revoked token stays dead. Before the first share
the sheet offers **Make link** and says that sharing creates one link to this
version. With a link it shows the link, a copy control, the version it opens,
and a confirmed **Revoke link** action. Revoking deactivates that URL
immediately: requests for a revoked token return 404 indistinguishable from an
unknown token, preventing existence oracles. Creating a fresh share link for the
artifact rotates the token, invalidating any previous link, and returns the sheet
to the Make link state.

The `url` the share endpoints return is absolute once `HUB_PUBLIC_URL` is set,
because an agent sharing outward has no document to resolve a relative link
against and would have to join a base URL it cannot know. Without a declared
public address it stays relative (`s/{token}`): behind a path-stripping proxy
the address off the request is the upstream's, with no prefix, and the relative
link resolves against the page it is on, which gets the prefix right.

The owner reads a shared artifact in the app like any other, because the
app asks the hub for a **viewer pass** before the frame names the page
(`GET /api/v1/artifacts/:id/viewer-pass`, admin-gated) and carries it in the
frame address. A pass is a keyed digest of the admin token, the artifact id and
a sixty-second window: it cannot be computed without the admin token, it reads
that one artifact's page and body and no other, and the hub recomputes it on
every request instead of storing it, so it expires on its own and leaves nothing
to keep. It exists because an iframe navigation carries no bearer token, which
is the credential the app reads everything else with. It grants nothing a
share recipient does not already have, and it is not the share token: the link
still serves its pinned version, and an unauthenticated `/artifacts/{id}` for a
shared artifact stays concealed while the link is live. The preview card takes
no pass, because a card names its artifact to anyone who asks for one.

For a protected artifact, the ciphertext is already protected by the client's
encryption key, which never reaches the hub. The share sheet provides a copy
control for the public URL (`/artifacts/{id}`) and a note that whoever published
it set its password, which the hub cannot reset or recover. Because protection
rests in the encryption key rather than server-side access revocation, withdrawal
is achieved by deleting the artifact, a **Delete artifact** action confirmed with
the words that every version and every link to it stop working.

When updating an artifact via the MCP tool or API, an update specifies what
happens to the protection:

| `envelope` on an update | What the new version is |
|---|---|
| left out | whatever the artifact carries now, ciphertext included |
| an envelope | protected under that envelope |
| `null` | in the clear, with `content` as plaintext |

Leaving it out inherits the existing protection, so an agent that relies on
inheritance sends ciphertext and the hub never stores that as if it were
plaintext. A version's protection is recorded per version, so `?version=N` and
`artifact_get` with a `version` each answer as that version was published: the
page serves the password gate for a protected one and reads a plain one, in the
same artifact.

## Comments

Artifacts carry discussion:

```
comment_post(artifact_id, body, anchor?, anchor_version?, idempotency_key?)
comment_list(artifact_id)
comment_resolve(artifact_id, comment_id, done, delete_token?)
comment_delete(artifact_id, comment_id, delete_token?)
```

Posting needs write access and returns the comment plus a delete token,
shown once. Resolving or deleting needs the token or write access; a
wrong token is refused without saying which part was wrong. A retry with
the same idempotency key returns the recorded comment without a second
token. The human reads and writes comments in the viewer drawer or desktop
margin cards; the public page shows the thread read-only, and never on a
protected artifact.

### Anchors and highlights

A comment may anchor to text or a point on a specific version:
- `anchor`: `{ mode: "text", quote: "..." }` or `{ mode: "point", x: number, y: number }`.
- `anchor_version`: the version number the anchor belongs to, defaulting to the current version.

In a commented document, body line-height rises from 1.6 to 1.7. An open comment
anchored to the version being read highlights its matched quote with a tint and
a 2px underline; point anchors place a pin glyph in the gutter. Quote matching
normalises whitespace and case, so minor formatting updates preserve the
highlight without notice to the reader.

When text changed underneath such that a quote no longer matches, or when reading
a newer version than the anchor, the comment is not highlighted in the text.
Selecting the comment or following its "open vX" link loads that historical version
with the anchor placed in its original context. Re-anchoring across versions is
dropped by design.

Resolved comments lose their highlight, return the text to regular body prose,
and collapse behind the resolved toggle. Protected artifacts refuse plain text
anchors on upload: the server never stores plain text quotes for encrypted
content.

## Version history

Versions are immutable snapshots once sealed. A change is normally a new
version published with `artifact_update`, and any version stays readable by
number after newer ones land. A stale `base_version` without `force` is refused
rather than overwritten.

A version can also be held live while an agent writes it. An agent begins a live
version with `artifact_draft`, which mints the next version and points the
artifact at it; every later `artifact_draft` mutates that one version in place
rather than minting another. The version being written is readable at
`?version=N`, or at `?live=1` when the number is not known. While a version is
live the artifact's default public URL keeps serving the last sealed version,
so a published link never shows half-finished work.

Publishing with `artifact_update` seals the live version: it becomes the current
version and is immutable like any other. A live version is not indexed for
search until it is sealed. Deleting an artifact removes its snapshots, blobs,
and search entry; the feed keeps the published, updated, and deleted events as
the audit trail.

## See also

- [Quickstart](quickstart.md) - build, run, and connect
- [Agent surface](../architecture/agent-surface.md) - the full MCP tool contract
- [Human surface](../architecture/human-surface.md) - the viewer and the API
- [Decision 0009](../adr/0009-browser-side-artifact-encryption.md) - why
  encryption happens in the browser
