Artifacts
Publish, version, protect, and view 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
markdownartifact 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
htmlartifact 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 - build, run, and connect
- Agent surface - the full MCP tool contract
- Human surface - the viewer and the API
- Decision 0009 - why encryption happens in the browser