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 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