# Artifacts API

> Publish self-contained HTML or Markdown pages to a shareable URL. Artifacts are versioned and permissioned, and update in place when you publish again.

**URL:** https://inference.sh/docs/api/rest/artifacts
**Last updated:** 2026-10-30

---

Publish self-contained HTML or Markdown pages to a shareable URL. Artifacts are versioned and permissioned, and update in place when you publish again.

This page is the REST reference. For the concepts and the CLI, see [Artifacts](/docs/artifacts), [Publishing](/docs/artifacts/publishing), [Capabilities](/docs/artifacts/capabilities), and [Sharing](/docs/artifacts/sharing).

Responses use the standard envelope. See [REST overview — response format](/docs/api/rest/overview#response-format).

---

## Scopes

| Scope | Access |
|-------|--------|
| `artifacts:read` | Post comments |
| `artifacts:write` | Create, publish versions, update metadata, delete, change visibility, share, transfer, upload and delete assets, activate and resolve comment threads |

Read routes (list, get, content, render, frame, comments, assets, viewer, page store) need no scope. They apply the artifact's visibility instead: `public` and `unlisted` artifacts are readable without authentication, and everything else requires a caller who is allowed to see it.

A reader is served the artifact's one live version: the latest, or the pinned one. Version history is for editors, meaning the owner, a user shared with `write` permission, or a workspace member. `GET /artifacts/{id}/versions` answers a viewer with **403**; `GET /artifacts/{id}/versions/{versionId}` and its `content` answer with **404**. `content`, `render` and `frame` return **404** to a viewer who names a version other than the live one.

Both scopes are in the `standard` preset. See [API keys — Permission scopes](/docs/api/rest/api-keys#permission-scopes).

---

## Endpoints

| Method | Path | Scope | Purpose |
|--------|------|-------|---------|
| `GET` | `/artifacts` | — | List artifacts |
| `POST` | `/artifacts/list` | — | List artifacts (JSON body) |
| `GET` | `/artifacts/{id}` | — | Get an artifact |
| `GET` | `/artifacts/{namespace}/{name}` | — | Get an artifact by name |
| `POST` | `/artifacts` | `artifacts:write` | Create an artifact, or publish a version when the name exists |
| `POST` | `/artifacts/{id}` | `artifacts:write` | Update metadata |
| `POST` | `/artifacts/{id}/versions` | `artifacts:write` | Publish a version |
| `DELETE` | `/artifacts/{id}` | `artifacts:write` | Delete |
| `GET` | `/artifacts/{id}/versions` | — | List versions. Editors only. |
| `POST` | `/artifacts/{id}/versions/list` | — | List versions (JSON body). Editors only. |
| `GET` | `/artifacts/{id}/versions/{versionId}` | — | Get one version. Editors only. |
| `GET` | `/artifacts/{id}/content` | — | Page source of the live version |
| `GET` | `/artifacts/{id}/versions/{versionId}/content` | — | Page source of one version. Editors only. |
| `GET` | `/artifacts/{id}/render` | — | Wrapped HTML document |
| `GET` | `/artifacts/{id}/frame` | — | Embed URLs for an iframe |
| `GET` | `/artifacts/{id}/stream` | — | Server-sent events for updates |
| `GET` | `/artifacts/guidance` | — | Authoring guidance as Markdown |
| `POST` | `/artifacts/{id}/visibility` | `artifacts:write` | Change visibility |
| `GET` | `/artifacts/{id}/shares` | `artifacts:write` | List shares |
| `POST` | `/artifacts/{id}/share` | `artifacts:write` | Share with a person, a workspace or the organization |
| `DELETE` | `/artifacts/{id}/share/{userId}` | `artifacts:write` | Revoke a person's share |
| `DELETE` | `/artifacts/{id}/team-share` | `artifacts:write` | Revoke a workspace's share (`?team_id=`, this workspace when absent) |
| `DELETE` | `/artifacts/{id}/org-share` | `artifacts:write` | Revoke the organization's share |
| `POST` | `/artifacts/{id}/transfer` | `artifacts:write` | Transfer to another workspace |
| `GET` | `/artifacts/{id}/comments` | — | List comment threads |
| `POST` | `/artifacts/{id}/comments` | `artifacts:read` | Post a comment |
| `POST` | `/artifacts/{id}/comments/{commentId}/activate` | `artifacts:write` | Send a thread to agents |
| `POST` | `/artifacts/{id}/comments/{commentId}/resolve` | `artifacts:write` | Resolve or reopen a thread |
| `GET` | `/artifacts/{id}/viewer` | — | Who is viewing (`user` capability) |
| `POST` | `/artifacts/{id}/data/get` `list` `set` `update` `delete` | — | Page store (`db` capability) |
| `GET` | `/artifacts/{id}/assets` | — | List assets |
| `GET` | `/artifacts/{id}/assets/{assetId}` | — | Download an asset |
| `POST` | `/artifacts/{id}/assets` | `artifacts:write` | Upload an asset |
| `DELETE` | `/artifacts/{id}/assets/{assetId}` | `artifacts:write` | Delete an asset |

---

## Artifact object

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Artifact ID |
| `namespace` | string | Owning workspace's username. Immutable. |
| `name` | string | Slug within the namespace. Immutable. |
| `title` | string | Display title |
| `description` | string | One-line summary |
| `favicon` | string | One or two emoji for the rendered page's browser tab |
| `images` | object | Cover images: `card`, `thumbnail`, `banner` (URL strings), and an optional `dark` object with the same three slots for the dark theme. Without `dark`, the slots serve both themes. |
| `cover_version_id` | string | The version a rendered cover shows. Empty when the cover was set with `images`, which a rendered cover never replaces. |
| `type` | string | `html` (default) or `markdown` |
| `version_id` | string | Latest published version ID |
| `version` | object | Latest version metadata |
| `shared_version_id` | string | Pinned version. When set, it is the live version viewers are served; empty means the latest. |
| `capabilities` | object | Runtime capabilities the latest version declared |
| `visibility` | string | `private`, `team`, `org`, `unlisted`, or `public` |
| `views` | integer | View count |
| `url` | string | Canonical viewer URL |
| `access` | string | The caller's level on the artifact: `read` (open it, read shared page data), `interact` (also change shared page data), or `write` (also publish and edit). On `GET /artifacts/{id}` and `GET /artifacts/{namespace}/{name}` only; lists omit it. |
| `created_at`, `updated_at` | string | RFC 3339 timestamps |

New artifacts are created `private`.

### Version object

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Version ID |
| `artifact_id` | string | Parent artifact ID |
| `number` | integer | 1-based publish sequence |
| `content` | object | Stored file reference (`uri`, `hash`, `size`). The source itself comes from the content endpoints. `uri` is empty when the version is stored in the private artifacts bucket — use the content endpoints instead of treating a bare storage key as a public URL. |
| `content_hash` | string | SHA-256 of the UTF-8 source |
| `md5` | string | Lowercase hex MD5 of the UTF-8 source |
| `size_bytes` | integer | Source byte length |
| `label`, `notes` | string | Optional version label and notes |
| `origin` | string | Provenance, for example `belt`, `chat:<id>`, `api` |
| `generated_by` | string | Who produced the version, for example `agent:<id>`, `human:<email>` |
| `capabilities` | object | Runtime capabilities this version declared |
| `created_by_user_id` | string | Publishing user |

Publishing identical content is a no-op. No duplicate version is created.

---

## Create or publish

`POST /artifacts` creates an artifact and its first version. When an artifact with the same `name` already exists in your namespace, it publishes a new version instead.

`POST /artifacts/{id}/versions` appends a version to an existing artifact.

Both accept `multipart/form-data` or JSON. Multipart is the recommended way to send page source.

### Multipart

| Part | Description |
|------|-------------|
| `file` | Page source (HTML or Markdown). Omit to update metadata without creating a version. |
| `file:<path>` | Another file of a multi-page artifact, served at `<path>` beside the page, for example `file:docs/setup.md`. Repeat per file. See [Pages and files](#pages-and-files). |
| `title`, `description`, `favicon`, `type` | Entry metadata |
| `name` | Slug on create. Derived from `title` when omitted. |
| `label`, `notes`, `origin`, `generated_by` | Version metadata |
| `image` | Cover URL. Sets `card`, `thumbnail`, and `banner` to the same value. |
| `capability` or `capabilities` | Repeat the field or comma-separate: `db`, `user`, `assets` |
| `base_version_id`, `force` | See [Version conflicts](#version-conflicts) |

```bash
curl -X POST https://api.inference.sh/artifacts \
  -H "Authorization: Bearer inf_your_key" \
  -F "file=@dashboard.html" \
  -F "title=Sales dashboard" \
  -F "type=html" \
  -F "capability=db"
```

```bash
curl -X POST https://api.inference.sh/artifacts/art_abc123/versions \
  -H "Authorization: Bearer inf_your_key" \
  -F "file=@dashboard.html" \
  -F "label=v2"
```

A folder as one artifact: the index is `file`, every other file goes as `file:<path>`.

```bash
curl -X POST https://api.inference.sh/artifacts \
  -H "Authorization: Bearer inf_your_key" \
  -F "file=@site/index.html" \
  -F "file:pricing.html=@site/pricing.html" \
  -F "file:data.json=@site/data.json" \
  -F "title=Launch notes"
```

### JSON

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | On create | Display title |
| `content` | string | Yes | Page source (HTML or Markdown) |
| `content_encoding` | string | No | `"base64"` when `content` is base64-encoded UTF-8 |
| `name` | string | No | Slug on create. Derived from `title` when omitted. |
| `description`, `favicon` | string | No | Entry metadata |
| `images` | object | No | Cover image URLs (`card`, `thumbnail`, `banner`, optional `dark` with the same slots) |
| `type` | string | No | `html` (default) or `markdown`. Create only. |
| `label`, `notes`, `origin`, `generated_by` | string | No | Version metadata |
| `capabilities` | object | No | For example `{"db": true, "user": true}`. An unknown name is refused. |
| `files` | array | No | Other files of a multi-page artifact: `[{"path", "content", "content_encoding"}]`. See [Pages and files](#pages-and-files). |
| `base_version_id` | string | No | Version the content was built on |
| `force` | boolean | No | Publish over a newer version and discard it |

```bash
curl -X POST https://api.inference.sh/artifacts \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Release notes",
    "type": "markdown",
    "content": "# v2.0\n\n- Faster renders"
  }'
```

### Pages and files

A version is its index page (`content` or `file`) plus, optionally, other files served beside it. A publish is the whole site: files of the previous version are not carried over.

- **Paths** are relative (`pricing.html`, `docs/setup.md`), use only `A-Z a-z 0-9 . _ ~ / -`, have no empty, `.` or `..` segment, and are at most 200 characters. `index.html` and `index.md` at the root are the index itself.
- **Types**: `.html`/`.htm` and `.md` are pages, rendered like the index. `.css`, `.js`/`.mjs`, `.json`, `.svg`, `.txt`, `.xml` and `.csv` are served as they are.
- **Addresses**: a page asks for `pricing` and gets `pricing`, `pricing.html`, `pricing/index.html` (or `index.md`) or `pricing.md`, in that order. A missing page is a **404**.
- **Links** between pages are relative and stay in the frame. The viewer's address follows them: `/artifacts/{id}/page/pricing`.
- **Loading**: a page may load and `fetch` its own files by relative URL. Every other host stays blocked.
- Limits: 200 files, and the index plus files together under the 16 MiB source limit.

The version object lists them as `files: [{path, size_bytes, content_type}]`.

### Version conflicts

Pass `base_version_id` with the `version_id` you read before editing. When the artifact has moved past that version, the publish is refused with **409** and code `version_conflict`. The `detail` names the current version. Re-read it, merge, and publish again.

| Request | Result |
|---------|--------|
| `base_version_id` omitted | Unconditional publish |
| `base_version_id` equals the current `version_id` | Publish proceeds |
| `base_version_id` is stale | **409** `version_conflict` |
| `force: true` | Publishes anyway and discards what landed in between |

---

## Update metadata

`POST /artifacts/{id}`

Updates `title`, `description`, `favicon`, `images`, or `shared_version_id`. Content changes go through a publish.

```json
{
  "title": "Updated title",
  "shared_version_id": "VERSION_ID"
}
```

`shared_version_id` pins a version, making it the live one viewers are served. Pass `""` to make the latest version live again. Pinning changes which version is live; editors keep access to every version. See [Sharing — Versions](/docs/artifacts/sharing#versions).

---

## Covers

A cover set with `images` on create, update or publish is a chosen cover: it clears `cover_version_id` and is never replaced.

Without a chosen cover, the API renders the latest version itself after a publish, when the platform's server covers are enabled (`artifacts.server_covers` is `on`). It renders the top of the page (1280×720) in the light and dark themes, in the background, and stores the images in `images` (and `images.dark` when the dark render differs), with `cover_version_id` set to that version. A version that is no longer the latest by the time its render finishes is not stored. A page whose content comes from the runtime (shared data, the viewer) renders its empty state. Until a render lands the artifact has no cover; `GET /artifacts/{id}/stream` reports the update.

---

## List and get

`GET /artifacts` or `POST /artifacts/list` returns artifacts you own or that were shared with you, with [cursor pagination](/docs/api/rest/overview#cursor-pagination).

`GET /artifacts/{id}` returns one artifact by ID.

`GET /artifacts/{namespace}/{name}` resolves by name. Append `@shortVersionId` to the name to get that version attached instead of the latest (for example `myteam/report@a1b2c3d4`). For a viewer, a version other than the live one is **404**.

`GET /artifacts/{id}/versions` or `POST /artifacts/{id}/versions/list` lists version metadata with cursor pagination. `GET /artifacts/{id}/versions/{versionId}` returns one version. Both require write access to the artifact: a viewer receives **403** from the list and **404** for a single version.

`DELETE /artifacts/{id}` deletes the artifact.

---

## Content

`GET /artifacts/{id}/content` returns the source of the live version (latest, or the pinned version).

`GET /artifacts/{id}/versions/{versionId}/content` returns one version. `versionId` is a full ID or the short ID. Editors only; a viewer receives **404** for any version but the live one.

| Request | Response |
|---------|----------|
| Default | JSON: `artifact_id`, `version_id`, `number`, `type`, `title`, `content`, `content_hash`, `md5`, `size_bytes` |
| `Accept: text/*` or `?raw=1` | Raw source body |

---

## Render

`GET /artifacts/{id}/render`

Returns the page wrapped in a full HTML document, as `belt artifact get --render` prints it.

| Query | Description |
|-------|-------------|
| `version` | Version ID or short ID. Default: the live version. A viewer naming any other version receives **404**. |
| `theme` | `dark` or `light` |

The response `Content-Type` is `text/plain`, so a browser shows the source instead of running it on the API origin. Browsers display published pages through [Frame](#frame).

---

## Frame

`GET /artifacts/{id}/frame`

Returns the URLs a client loads to show a published page. Both point at the user-content domain, never the API host. See [Artifacts — Where a page runs](/docs/artifacts#where-a-page-runs). Accepts the same `version` and `theme` query parameters as [Render](#render), and `path` to open a page of a multi-page artifact (for example `path=docs/setup`), and runs the same read check as `GET /artifacts/{id}`. A viewer is framed the live version only; any other `version` is **404**.

| Field | Type | Description |
|-------|------|-------------|
| `artifact_id` | string | Artifact ID |
| `version_id` | string | Resolved version ID |
| `version_short_id` | string | Version short ID |
| `embed_url` | string | URL for an `<iframe>` |
| `top_url` | string | The same page opened in its own tab |
| `exchange` | boolean | `true` when the URLs carry a short-lived access token. `false` for a public artifact, which needs none. |
| `expires_at` | string | When the URL token expires. Omitted when `exchange` is `false`. |

URL tokens are valid for about **10 minutes**. Once a URL has been opened, the page stays accessible in that browser for about **55 minutes**. Request a new frame after that.

```bash
curl "https://api.inference.sh/artifacts/art_abc123/frame?theme=dark" \
  -H "Authorization: Bearer inf_your_key"
```

---

## Authoring guidance

`GET /artifacts/guidance`

Returns the page contract with the design, capability, and diagram guidance. It is the text `belt artifact guidance` prints. No authentication required. The response body is `text/markdown`, not JSON.

| Query | Description |
|-------|-------------|
| `topics` | Comma-separated: `design`, `capabilities`, `diagrams`. Omit for all. |

---

## Live updates

`GET /artifacts/{id}/stream`

Server-sent events for artifact updates such as new versions and metadata changes. See [Streaming](/docs/api/rest/streaming).

The opening event uses the same viewer-specific payload as `GET /artifacts/{id}` (including a pinned `shared_version_id`). Later events do too: if you follow a pinned artifact without write access, each update still names the pinned version when the owner publishes a newer one. Callers with write access receive events for the latest version. Whether you are treated as an editor is decided when the stream opens.

---

## Visibility, sharing, transfer

`POST /artifacts/{id}/visibility`

```json
{ "visibility": "unlisted" }
```

| Value | Who can view |
|-------|--------------|
| `private` | The author (default on create) |
| `team` | Workspace members |
| `org` | Organization members |
| `unlisted` | Anyone with the link |
| `public` | Anyone |

`POST /artifacts/{id}/share` shares with one grantee: a person, a workspace, or the whole organization.

```json
{ "user_id": "USER_ID", "permission": "read" }
{ "team": true, "permission": "read" }
{ "team_id": "TEAM_ID", "permission": "read" }
{ "org": true, "permission": "read" }
```

`team: true` is the artifact's own workspace. `team_id` names another workspace, which must be in the same organization. `org: true` is the artifact's organization. A share never reaches past the organization: a workspace outside it is refused with **400** `validation_error`. A workspace that is in no organization shares only inside itself.

`permission` is `read` (viewer: the live version, comments, and writes to the page's shared data) or `write` (editor: can publish versions, read the version history and manage shares). `use` gives the same as `read`. `GET /artifacts/{id}/shares` lists shares, each with `grantee_type` (`user`, `team` or `org`) and `grantee_id`. Revoke with `DELETE /artifacts/{id}/share/{userId}`, `DELETE /artifacts/{id}/team-share?team_id=TEAM_ID` or `DELETE /artifacts/{id}/org-share`.

The share routes require an API key with `artifacts:write` **and** write access on the artifact itself (owner or an editor share). A read-only collaborator gets **403**; callers who cannot read the artifact get **404**.

`POST /artifacts/{id}/transfer` moves the artifact to another workspace:

```json
{ "team_id": "TEAM_ID" }
```

→ [Sharing](/docs/artifacts/sharing)

---

## Comments

`GET /artifacts/{id}/comments` returns an array of threads. Each thread is a root comment with its `replies` in chronological order. Anyone who can read the artifact can list its threads.

`POST /artifacts/{id}/comments` requires `artifacts:read`.

| Field | Description |
|-------|-------------|
| `content` | Comment body (required) |
| `parent_comment_id` | Thread root to reply to. Omit to start a thread. |
| `send_to_agent` | Activate the thread for agents in the same request |

Comments are disabled on `public` artifacts. The request returns **409** with code `comments_unavailable`.

`POST /artifacts/{id}/comments/{commentId}/activate` sets `agent_activated` on a thread root. Agents can read every thread but can only reply to or resolve activated threads.

`POST /artifacts/{id}/comments/{commentId}/resolve` resolves a thread. Send `{"resolved": false}` to reopen it.

---

## Runtime capabilities

A page declares capabilities at publish time and gets only what its published version declared. [Capabilities](/docs/artifacts/capabilities) covers the `window.inferencesh` JavaScript API. The routes below are what the viewer calls on the page's behalf. Each is refused unless the artifact declared the matching capability.

### Page store (`db`)

`POST /artifacts/{id}/data/get`, `/data/list`, `/data/set`, `/data/update`, `/data/delete`

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `collection` | string | Yes | Collection path, for example `poll` or `data/users/me/prefs` |
| `doc_id` | string | For get, set, update, delete | Document ID within the collection |
| `data` | object | For set, update | JSON document body |
| `limit` | integer | No | Max documents on one list page. Clamped to 200. |
| `cursor` | string | No | Continues a list from the `next_cursor` of the page before |
| `order` | string | No | List order: `-updated_at` (default), `updated_at`, `-created_at` or `created_at` |
| `where` | object | No | List only documents whose top-level fields equal these values: string, number, boolean, or `null` for a missing field. Up to 8 fields; names are letters, digits and `_`. |

`set` replaces a document and creates it when missing. `update` merges fields. Reads follow the artifact's visibility. Writes to a shared collection need `interact` on the artifact: its workspace, its share holders, and its org's members when it is org-visible. A public or unlisted link alone grants `read`, and such a write is **403**. Documents under `data/users/me` are private to the signed-in caller who wrote them; anyone who can open the page may write their own, and a signed-out caller has none.

A document response has `collection`, `doc_id`, `data`, `updated_at`, and `owner_user_id` (set for per-viewer documents). A list response has `collection`, `documents`, `count`, and `next_cursor` when another page follows. A cursor is only valid with the `order` it was returned for.

```bash
curl -X POST https://api.inference.sh/artifacts/art_abc123/data/get \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"collection": "poll", "doc_id": "totals"}'
```

`data/get` returns **404** `not_found` with `document not found` when the artifact is readable but the document does not exist. `data/list` returns an empty `documents` array for an empty collection.

### Viewer identity (`user`)

`GET /artifacts/{id}/viewer`

| Field | Type | Description |
|-------|------|-------------|
| `signed_in` | boolean | `false` for someone opening a public link without an account |
| `user_id` | string | Present when signed in |
| `name` | string | Display name |
| `avatar_url` | string | Profile image URL |
| `can_edit` | boolean | Whether this viewer is an editor: may publish new versions and read the version history |

No email or credential is included.

### Assets (`assets`)

Files stored beside an artifact. Page sources and assets are written to a dedicated private object store (not the shared bucket behind public task file URLs). Anyone who can view the artifact can read its assets through the API; upload and delete require `artifacts:write`. Uploading identical bytes twice returns the existing asset.

`POST /artifacts/{id}/assets` takes multipart form data with a `file` part and an optional `content_type` field.

```bash
curl -X POST https://api.inference.sh/artifacts/art_abc123/assets \
  -H "Authorization: Bearer inf_your_key" \
  -F "file=@chart.png"
```

| Field | Type | Description |
|-------|------|-------------|
| `asset_id` | string | Asset ID |
| `filename` | string | Original filename |
| `content_type` | string | MIME type |
| `size_bytes` | integer | File size |
| `url` | string | Address to use in page markup, exactly as returned |
| `created_at` | string | RFC 3339 timestamp |
| `uploaded_by_user_id` | string | Uploading user |

`GET /artifacts/{id}/assets` returns `assets`, `count`, `total_bytes`, and `budget_bytes`.

`GET /artifacts/{id}/assets/{assetId}` checks that the caller may view the artifact, then redirects (**302**) to a storage URL valid for **15 minutes**, or streams the bytes.

`DELETE /artifacts/{id}/assets/{assetId}` removes the asset and its stored bytes.

---

## Limits

| Limit | Value |
|-------|-------|
| Page source per publish | **16 MiB**, the index and its files together |
| Files beside the index | **200** |
| Asset file | **25 MiB** |
| Assets per artifact | 250 MiB and 500 files |
| Asset upload through the MCP `artifact_asset_upload` tool | **8 MiB** per file |
| Page store document | **128 KiB** of stored JSON |
| Page store per artifact | **16 MiB** and **10,000 documents** |
| Page store list | 200 documents per call |
| Collection path / document ID length | 512 / 200 characters |

A page store write over a limit returns **400** with code `validation_error`. Replacing an existing document is measured against the new payload only.

---

## Related

- [Artifacts](/docs/artifacts) — what a page is and where it runs
- [Publishing](/docs/artifacts/publishing) — `belt artifact publish`, flags, updating in place
- [Capabilities](/docs/artifacts/capabilities) — `db`, `user`, `assets` from inside a page
- [Sharing](/docs/artifacts/sharing) — visibility, pinned versions, comments
- [API keys](/docs/api/rest/api-keys) — `artifacts:read` and `artifacts:write` scopes
