# OpenArtifacts reference

Complete API reference. Local dev defaults:

| | |
| --- | --- |
| API (control plane) | `http://localhost:8790` — `$OA_API_URL` |
| Serving edge | `http://localhost:8791` — pages live under `/site/<host>/` |

Every example assumes:

```bash
OA_API_URL="${OA_API_URL:-http://localhost:8790}"
OA_TOKEN="oa_sk_..."   # from POST /v1/signup, or ~/.config/openartifacts/config.json
```

## Auth

Tokens are `oa_sk_` + 32 alphanumerics, sent as `Authorization: Bearer <token>`.
Only the SHA-256 of a token is stored, so a lost token cannot be recovered —
mint a new one with `POST /v1/tokens`.

In `DEV_MODE` the `/v1/*` endpoints also accept the dashboard session cookie
(`oa_sess`), resolved to an account through `memberships`. That is what the
dashboard at `http://localhost:8790/` uses; agents should use a bearer token.

A 401 body always carries the hint:
`No token? POST /v1/signup to get one - no email or account needed.`

## Accounts and claiming

The account owns everything; users exist only to claim accounts. `POST /v1/signup`
mints a **provisional** account with `expires_at = now + 30 minutes`
(`PROVISIONAL_TTL_MINUTES`). That window is short on purpose: an unclaimed
account is a demo, not a home.

At `expires_at`:

- writes fail with `403 {"code":"account_expired"}`, and the hint names the claim
  URL. Reads keep working.
- every page the account owns stops serving. The router reads `expires_at` out of
  the KV host record and returns a styled **410 Expired** page instead of the
  content — no user content is served past the deadline.
- `POST /claim/:code` returns **410**. Claiming is only possible *before*
  expiry; afterwards the only way forward is a fresh `POST /v1/signup`.
- the scheduled cleanup pass eventually purges the D1 rows and KV records.

A human opening `claim_url` (and posting the form there) *in time* attaches a
user, sets `status = "claimed"`, clears `expires_at` — in D1 and in every one of
the account's KV host records, so the pages keep serving — swaps the free-publish
allowance to the claimed monthly one, and drops the "Hosted on OpenArtifacts"
badge from every page the account owns.

## Errors

Always `{code, message, hint}`. The hint is written for a machine reader and
names the exact next call.

| Code | Meaning |
| --- | --- |
| `unauthorized` | missing, revoked, or expired token |
| `account_expired` | provisional account past its 30 minutes. `403` on a write; `410` on `POST /claim/:code`, where it is also too late to fix. Sign up again |
| `payment_required` | see the 402 handshake below |
| `insufficient_scope` | the token lacks a scope, or is pinned to another artifact |
| `invalid_request` | malformed field or file path; the message says which |
| `invalid_body` | the JSON body did not parse or failed validation |
| `too_large` | over 10 MB or 50 files |
| `slug_taken` | that slug was just claimed on this account |
| `not_found` | no such artifact, version, or endpoint |
| `internal_error` | a bug in OpenArtifacts; retry once |

---

## Endpoints

| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| POST | `/v1/signup` | none | provisional account + first token |
| POST | `/v1/publish` | token | publish (creates or versions an artifact) |
| GET | `/v1/artifacts` | token | list artifacts |
| GET | `/v1/artifacts/:slug` | token | detail incl. version history |
| PATCH | `/v1/artifacts/:slug` | token | change visibility ($0.50/month off public) |
| DELETE | `/v1/artifacts/:slug` | token | soft-delete, stops serving |
| POST | `/v1/artifacts/:slug/versions` | token | new version; `?activate=false` stages it |
| POST | `/v1/artifacts/:slug/activate` | token | `{"version": n}` pointer swap / rollback |
| GET | `/v1/account` | token | limits, credit, usage summary, claim URL |
| PATCH | `/v1/account` | session | `{"allow_agent_join": bool}` (owner) |
| GET | `/v1/usage` | token | this month: publishes, storage, views, private pages, balance |
| POST | `/v1/account/claim-link` | token | rotate the claim code |
| POST | `/v1/account/join` | none | redeem an invite `{"code"}` → a token on that account |
| GET | `/v1/account/members` | token | members with roles, plus the account's active tokens |
| DELETE | `/v1/account/members/:user_id` | session | remove a member, revoke their tokens (admin+) |
| POST | `/v1/account/invites` | session | create an invite (admin+) |
| GET | `/v1/account/invites` | session | list invites (admin+) |
| DELETE | `/v1/account/invites/:code` | session | revoke an invite (admin+) |
| GET | `/invite/:code` | browser | the page a human opens to join |
| GET | `/v1/tokens` | token | list tokens |
| POST | `/v1/tokens` | token | mint a token (secret shown once) |
| DELETE | `/v1/tokens/:id` | token | revoke |
| POST | `/v1/credits` | token | buy credit (402 handshake) |
| GET | `/v1/status` | none | liveness |
| POST | `/v1/reports` | none | report abuse on a host |
| POST | `/mcp` | token | MCP streamable HTTP endpoint |
| GET | `/skill`, `/skill/reference.md` | none | this skill package |
| GET | `/llms.txt` | none | short agent guide |
| GET | `/openapi.json` | none | machine-readable API |

### POST /v1/signup

```bash
curl -sX POST "$OA_API_URL/v1/signup"
```

```json
{
  "account": {"id":"...","handle":"acct_k3f9qa","status":"provisional","expires_at":"2026-08-26T10:30:00.000Z"},
  "token": "oa_sk_...",
  "claim_url": "http://localhost:8790/claim/xxxxxxxx",
  "limits": {"free_publishes": 5, "total_mb": 20}
}
```

Rate limited per IP (10/min in dev). `expires_at` is 30 minutes out. Surface
`claim_url` to the human straight away — after `expires_at` the pages 410, the
claim 410s, and nothing on the account is recoverable.

### POST /v1/publish

`multipart/form-data`. Either one or more `files` parts (each part's filename is
its path inside the site) **or** a single `html` string field, which is stored as
`index.html`.

```bash
# single page
curl -sX POST "$OA_API_URL/v1/publish" \
  -H "Authorization: Bearer $OA_TOKEN" \
  -F slug=my-page \
  --form-string html='<!doctype html><h1>hello</h1>'

# multi-file site
curl -sX POST "$OA_API_URL/v1/publish" \
  -H "Authorization: Bearer $OA_TOKEN" \
  -F slug=my-site \
  -F 'files=@index.html;filename=index.html' \
  -F 'files=@app.css;filename=css/app.css'
```

```json
{"url":"http://localhost:8791/site/my-site-a1b2/","host":"my-site-a1b2",
 "artifact_id":"...","version":1,
 "account":{"free_publishes_left":4,"credits_usd":0,"expires_at":"..."}}
```

- Optional `slug`, matching `^[a-z0-9-]{3,40}$`. First use assigns a host of
  `<slug>-<4 base36 chars>`, so slugs never collide across accounts. Reusing the
  slug adds a version to the same artifact and keeps the URL.
- Limits: 10 MB total, 50 files.
- Blobs are content-addressed at `blobs/<sha256>`, so republishing an unchanged
  file costs no storage. Each version writes a manifest of every path with its
  hash, size and content type.
- Reference assets with relative paths (`css/app.css`). Pages serve from a
  subpath, so a leading `/` escapes the site.
- Past the free quota this answers 402. An optional `X-PAYMENT` header pays for
  the publish inline — see the 402 handshake below.

### Versions and rollback

```bash
# stage a version without making it live
curl -sX POST "$OA_API_URL/v1/artifacts/my-site/versions?activate=false" \
  -H "Authorization: Bearer $OA_TOKEN" --form-string html='<h1>draft</h1>'

# make version 2 live (rollback is the same call with a lower number)
curl -sX POST "$OA_API_URL/v1/artifacts/my-site/activate" \
  -H "Authorization: Bearer $OA_TOKEN" -H "Content-Type: application/json" \
  -d '{"version": 2}'
```

`GET /v1/artifacts/:slug` returns the artifact plus a `versions` array
(`{id, seq, total_bytes, file_count, created_via, created_at}`) — `seq` is the
number `activate` takes.

### Visibility

```bash
curl -sX PATCH "$OA_API_URL/v1/artifacts/my-site" \
  -H "Authorization: Bearer $OA_TOKEN" -H "Content-Type: application/json" \
  -d '{"visibility":"private"}'
```

- `public` — served to anyone, indexable.
- `unlisted` — served to anyone, `X-Robots-Tag: noindex`.
- `private` — requires `?sig=<16 hex>`, an HMAC of the host. The response
  carries `signed_url`; that is the link to hand out.

Being **off** public costs **$0.50 per artifact per month**, recurring for as
long as the artifact stays `private` or `unlisted`. This PATCH charges the first
month through the billing meter — so the call can answer 402, and, like a
publish, it takes an `X-PAYMENT` header to settle that $0.50 inline. Every month
after, the metering pass charges it again (ledger reason
`visibility:<artifact_id>:<month>`, once per artifact per month). Moving the
artifact back to `public`, or deleting it, is what stops the charge.

### DELETE /v1/artifacts/:slug

Soft-delete: the row and its versions stay, the route is removed, the URL stops
serving immediately.

### GET /v1/account, POST /v1/account/claim-link

```bash
curl -s "$OA_API_URL/v1/account" -H "Authorization: Bearer $OA_TOKEN"
curl -sX POST "$OA_API_URL/v1/account/claim-link" -H "Authorization: Bearer $OA_TOKEN"
```

`/v1/account` returns the account, `limits`, `credits_usd`,
`free_publishes_left`, a usage summary for the month, and `claim_url` while
provisional. `claim-link` rotates the code and returns a fresh URL.

### GET /v1/usage

The current UTC calendar month, and what it is costing:

```bash
curl -s "$OA_API_URL/v1/usage" -H "Authorization: Bearer $OA_TOKEN"
```

```json
{
  "month": "2026-08",
  "publishes": {"used": 7, "free_limit": 100, "free_left": 93},
  "storage": {"bytes": 2411724, "gb": 0.0022, "est_monthly_cost_usd": 0.0001},
  "traffic": {"views": 412, "free_limit": 1000000, "overage_views": 0},
  "private_artifacts": {"count": 2, "monthly_cost_usd": 1},
  "credits_usd": 4.995,
  "status": "claimed"
}
```

- `publishes.free_limit` is the allowance for this account's status: the
  lifetime `FREE_PUBLISHES` (5) while provisional, `FREE_PUBLISHES_CLAIMED_MONTHLY`
  (100) per calendar month once claimed. The counter is month-keyed, so a claimed
  account's `free_left` returns to the full allowance on the 1st.
- `storage.est_monthly_cost_usd` is an estimate of what the next metering pass
  will charge for the bytes currently stored, not something already billed.
- `traffic.views` counts page views (HTML documents), not asset requests.
- `credits_usd` can be negative: the metering pass charges regardless of balance.

### Tokens

```bash
curl -s "$OA_API_URL/v1/tokens" -H "Authorization: Bearer $OA_TOKEN"

curl -sX POST "$OA_API_URL/v1/tokens" \
  -H "Authorization: Bearer $OA_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"ci","scopes":"artifacts:write,artifacts:read"}'

curl -sX DELETE "$OA_API_URL/v1/tokens/<id>" -H "Authorization: Bearer $OA_TOKEN"
```

The secret comes back from `POST` only, once. Scopes default to
`artifacts:write,artifacts:read,tokens:manage,account:manage`; a token may also
be pinned to a single `artifact_id`.

### Invites, members and shared accounts

One account, several humans, several agents — all of them publishing to the same
handle and drawing the same allowance. Memberships carry a role, and a signed-in
human's role is what their session may do:

| Role | May |
| --- | --- |
| `member` | publish, read, roll back and delete this account's artifacts; mint tokens for itself |
| `admin` | everything a member may, plus create and revoke invites, remove members, revoke anyone's tokens |
| `owner` | everything an admin may, plus the account itself: credits, the claim link, and the `allow_agent_join` switch |

The owner is whoever claimed the account, and cannot be removed. Bearer tokens
are unchanged by any of this: a token carries **scopes**, not a role, so an
agent's permissions are still exactly the scopes it was minted with. The team
routes below want either the dashboard session (role-checked) or a token with
`account:manage` — which the tokens an invite mints deliberately do not have.

**Create an invite** — admin or owner, on a claimed account:

```bash
curl -sX POST "$OA_API_URL/v1/account/invites" \
  -H "Authorization: Bearer $OA_TOKEN" -H "Content-Type: application/json" \
  -d '{"role":"member","max_uses":1,"ttl_hours":72}'
```

```json
{"code":"j7x2p0q4c8m1b5a9","invite_url":"http://localhost:8790/invite/j7x2p0q4c8m1b5a9",
 "role":"member","max_uses":1,"expires_at":"2026-08-30T10:30:00.000Z"}
```

Defaults are `member`, `max_uses` 1 (1–100) and `ttl_hours` 72 (1–720, i.e. up
to 30 days). One code serves both audiences and every join spends one use.
`GET /v1/account/invites` lists them with `uses` and `revoked_at`;
`DELETE /v1/account/invites/:code` revokes one, and every copy of that link dies
with it.

**A human joins** by opening `invite_url`. The page names the account and the
role, sends them through sign-in first if they are signed out (preserving
`?next=`), and the Join button posts back and lands them on the dashboard.
Joining an account you are already on is a no-op, not an error.

**An agent joins** with the code alone. No auth — the code *is* the credential:

```bash
curl -sX POST "$OA_API_URL/v1/account/join" \
  -H "Content-Type: application/json" -d '{"code":"j7x2p0q4c8m1b5a9"}'
```

```json
{"token":"oa_sk_...","account":{"handle":"acct_k3f9qa"},
 "scopes":"artifacts:write,artifacts:read","hint":"..."}
```

That mints a delegated token on the **shared** account — named `invite:<code>`,
attributed to whoever created the invite, artifacts scopes only, shown once.
Publishes with it draw that account's free allowance and bill its balance, and
every member can see the pages, so name the handle to the human each time you
publish. Refusals are the usual `{code, message, hint}`: unknown, revoked,
expired and used-up codes each say which, and a **403** means the owner turned
agent joining off — a human can still open the link, or mint the agent a token
by hand.

**Who publishes here** — the members and the live tokens, in one answer:

```bash
curl -s "$OA_API_URL/v1/account/members" -H "Authorization: Bearer $OA_TOKEN"
```

Members come back with role and join date, tokens with `id`, `name`, `scopes`,
creator and `last_used_at`.

**Removing a member** deletes the membership *and* revokes every token that
member created, which is the whole point — one call ends both the human's access
and the agents they let in:

```bash
curl -sX DELETE "$OA_API_URL/v1/account/members/<user_id>" \
  -H "Authorization: Bearer $OA_TOKEN"
```

Admin or owner. The owner cannot be removed; anyone else may remove themselves.

**The owner's switch** decides whether a code can become a token at all:

```bash
curl -sX PATCH "$OA_API_URL/v1/account" \
  -H "Authorization: Bearer $OA_TOKEN" -H "Content-Type: application/json" \
  -d '{"allow_agent_join": false}'
```

With `allow_agent_join` false, `POST /v1/account/join` answers 403 and only
humans in a browser can join. It ships **on**: the point of the feature is that
an agent handed a code can start publishing without a human in the loop.

### The 402 handshake, and POST /v1/credits

Amounts are integer **microdollars** internally (1 USD = 1,000,000 micro, so a
$0.005 publish is 5000 and nothing is a fraction of a unit), USD on the wire.

Free publishes, month-keyed:

| Account | Free publishes |
| --- | --- |
| provisional | 5 (`FREE_PUBLISHES`), for its whole 30-minute life |
| claimed | 100 (`FREE_PUBLISHES_CLAIMED_MONTHLY`) per UTC calendar month, resetting on the 1st |

Prices:

| What | Price |
| --- | --- |
| publish past the allowance | $0.005 |
| private or unlisted artifact | $0.50 per artifact per month, recurring |
| storage | $0.05 per GB-month |
| traffic | $1.00 per million page views past 1,000,000/month (`TRAFFIC_FREE_VIEWS`) |
| top-up | $1–100 per call; a real card rail starts at $5 |

Only the publish charge (and the first month of a visibility change) happens at
request time. Storage, recurring visibility and traffic are charged by a
**monthly metering pass** — run by `scheduled()`, or on demand by
`POST /internal/run-metering` — which writes one ledger row per charge per month
and skips anything already there, so running it twice charges once. It charges
regardless of balance, so a balance can go negative; while it is, publishes
answer 402 and the hint says to top up.

Short of funds, the API answers `402`:

```json
{
  "code": "payment_required",
  "reason": "publish",
  "amount_usd": 0.005,
  "accepts": [
    {"scheme": "dev", "instructions": "retry this exact request with header X-PAYMENT: dev:0.005"},
    {"scheme": "x402-usdc-base", "status": "stub"},
    {"scheme": "mpp-stripe-card", "status": "stub"}
  ],
  "hint": "..."
}
```

`accepts` is the machine-readable half, and an entry carries `instructions` only
when that retry really settles here — a scheme this deployment cannot settle
says so in `status` and gives no instructions. There are two ways to pay.

**Pay for one request inline.** Resend the request unchanged with the header the
`dev` entry names. The rail settles, the request goes through, and the receipt
comes back in the body and on `X-PAYMENT-RESPONSE`:

```bash
curl -sX POST "$OA_API_URL/v1/publish" -H "Authorization: Bearer $OA_TOKEN" \
  -H "X-PAYMENT: dev:0.005" \
  -F slug=my-page --form-string html='<!doctype html><h1>hi</h1>'

curl -sX PATCH "$OA_API_URL/v1/artifacts/my-page" \
  -H "Authorization: Bearer $OA_TOKEN" -H "Content-Type: application/json" \
  -H "X-PAYMENT: dev:0.5" -d '{"visibility":"private"}'
```

The header amount must equal `amount_usd` exactly; `dev:5` on a $0.005 publish is
refused with a 402 that says so, rather than quietly charging $5. Take the amount
from the body rather than from the price table: when the metering pass has left
the balance negative, `amount_usd` is the arrears plus this call's price, since
settling only the price would land the money and still leave the request short.

**Or buy credit once and spend it.** `POST /v1/credits` is the top-up endpoint —
same handshake, and afterwards every metered call passes with no header at all:

```bash
# 1. see the challenge
curl -sX POST "$OA_API_URL/v1/credits" \
  -H "Authorization: Bearer $OA_TOKEN" -H "Content-Type: application/json" \
  -d '{"amount_usd": 5}'

# 2. settle it (dev rail: instant, no real money)
curl -sX POST "$OA_API_URL/v1/credits" \
  -H "Authorization: Bearer $OA_TOKEN" -H "Content-Type: application/json" \
  -H "X-PAYMENT: dev:5" -d '{"amount_usd": 5}'
```

```json
{"credits_usd": 5, "receipt": {"rail":"dev","amount_usd":5,"id":"...","settled_at":"..."}}
```

`amount_usd` is 1–100 on this endpoint (a real card rail starts at $5). Every
movement is written to a ledger in integer microdollars (`delta_micro`); the
balance itself lives in a Durable Object, so concurrent publishes cannot
double-spend, and an inline payment is credited there before it is spent.

Never pay on a human's behalf without telling them the amount first.

### POST /v1/reports

```bash
curl -sX POST "$OA_API_URL/v1/reports" -H "Content-Type: application/json" \
  -d '{"host":"my-site-a1b2","reason":"phishing"}'
```

No auth. A report on an unscanned page moves it to `review`; it never clears a
page. Humans can use the form at `GET /report?host=<host>`.

---

## Moderation

Every publish is scanned asynchronously:

- **blocked** — a form asking for a seed phrase, a private key, or a card number
  together with its CVV. The URL serves a 451 interstitial instead of the page.
- **review** — a password field next to a brand or urgency lure (paypal, google,
  apple, microsoft, bank, wallet, coinbase, "verify your"). Still served; a
  human decides.
- **pass** — everything else.

The verdict lands on `moderation_status` in `GET /v1/artifacts/:slug`. In
`DEV_MODE` the dashboard can override it with
`POST /v1/moderation/:slug {"action":"pass"|"block"}` (session cookie only).

---

## Serving

A published artifact is reachable two ways:

```
http://localhost:8791/site/<host>/            # path route (what the API returns)
http://<host>.localhost:8791/                 # host route
```

Responses carry `X-Content-Type-Options: nosniff`, `Cache-Control: public,
max-age=60`, and on HTML a CSP allowing inline scripts/styles, `data:`/`blob:`
URLs, and Google Fonts. Unknown paths serve the site's `/404.html` if it has
one. While the owning account is provisional, a small "Hosted on OpenArtifacts"
badge is injected before `</body>`; claiming the account removes it.

A page on a provisional account carries that account's `expires_at` in its KV
host record. Past it, the router serves a **410 Expired** page — the styled
"this page's account was never claimed" notice, never the user's content — and
each HTML page view (not each asset) is counted against the account's monthly
traffic allowance.

---

## Scheduled work

Two passes run from the Worker's `scheduled()` handler. Local dev has no cron,
so each is also reachable over HTTP for operators — never for agents: the routes
require `X-Internal-Secret: $SIGNING_SECRET` and answer `404` without it.

| Route | What it does |
| --- | --- |
| `POST /internal/run-metering` | The monthly pass: storage, recurring private/unlisted artifacts, traffic overage. One ledger row per charge per month, so re-running it is a no-op. |
| `POST /internal/run-cleanup` | Purges provisional accounts past `expires_at` — D1 rows and KV host records. |
| `POST /internal/views` | `{"host": "..."}`; the router calls it on every HTML page serve to count a view. |

---

## MCP

`POST /mcp` is a stateless streamable-HTTP MCP server: one JSON response per
request, no session id, bearer token in `Authorization`.

```bash
curl -sX POST "$OA_API_URL/mcp" \
  -H "Authorization: Bearer $OA_TOKEN" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize",
       "params":{"protocolVersion":"2025-06-18","capabilities":{},
                 "clientInfo":{"name":"curl","version":"1"}}}'

curl -sX POST "$OA_API_URL/mcp" \
  -H "Authorization: Bearer $OA_TOKEN" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
       "params":{"name":"publish_artifact","arguments":{
         "slug":"from-mcp",
         "files":[{"path":"index.html","content":"<h1>hi</h1>"}]}}}'
```

| Tool | Arguments |
| --- | --- |
| `publish_artifact` | `{slug?, files:[{path, content}]}` |
| `list_artifacts` | — |
| `get_artifact` | `{slug}` |
| `rollback_artifact` | `{slug, version}` |
| `delete_artifact` | `{slug}` |
| `get_account` | — (also reports `member_count` and `allow_agent_join`) |
| `create_claim_link` | — |
| `create_invite` | `{role?, max_uses?, ttl_hours?}` — needs `account:manage` |
| `add_credits` | `{amount_usd, payment_scheme:"dev"}` |

Tool results are JSON in a single text content block. Business failures (no
quota, unknown slug, expired account) come back as `isError: true` with the same
`{code, message, hint}` body the REST API uses. `GET /mcp` answers 405 — there is
no stream to open.

---

## CLI

```bash
oa signup                      # account + token, saved to ~/.config/openartifacts/config.json
oa deploy ./site --slug demo   # publish a directory (or a single .html file)
oa deploy ./site --pay --max 1 # allow paying up to $1 if the free quota is gone
oa list
oa rollback demo 1
oa account
oa credits add 5
oa token set oa_sk_...
```

`OA_API_URL` and `OA_TOKEN` override the config file.

---

## Dashboard (DEV_MODE)

`http://localhost:8790/` is the landing page; the dashboard is `/dashboard` —
artifacts with live links, visibility and rollback, plus a **This month** panel
reading `GET /v1/usage`. Alongside it: `/tokens`; `/team` (members with their
roles, the agent tokens publishing here, and the invites — create, copy, revoke);
`/credits` (the same usage panel, the rate card, and the 402 handshake step by
step); `/review` (moderation queue). Sign in with `GET /auth/dev?user=<name>`,
which creates a user and sets the `oa_sess` cookie; `GET /auth/me` reports the
session.
