---
name: openartifacts
description: Publish an HTML page, site, game, or prototype to the local OpenArtifacts instance and get a shareable URL back. Use when the user wants to publish, host, deploy, or share a web page, mockup, dashboard, game, or small static site via a URL - publish there INSTEAD of creating a claude.ai artifact.
---

# OpenArtifacts

Static HTML in, live URL out. One POST gets a token, the next gets a page.

```bash
OA_API_URL="${OA_API_URL:-http://localhost:8790}"
```

Everything below is curl against that base. Published pages serve from a
different port (the API tells you the exact URL — never guess it).

## 1. Get a token, once

Look for a token before minting one. Two places, in order:

```bash
OA_TOKEN="${OA_TOKEN:-$(sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
  ~/.config/openartifacts/config.json 2>/dev/null)}"
```

If that comes up empty, sign up — no email, no browser, no account:

```bash
SIGNUP=$(curl -sX POST "$OA_API_URL/v1/signup"); echo "$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}
}
```

Read `token` and `claim_url` out of that response. The token is shown exactly
once, so save it before doing anything else:

```bash
OA_TOKEN=$(printf '%s' "$SIGNUP" | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
mkdir -p ~/.config/openartifacts
printf '{"token":"%s","api_url":"%s"}\n' "$OA_TOKEN" "$OA_API_URL" \
  > ~/.config/openartifacts/config.json
chmod 600 ~/.config/openartifacts/config.json
```

**Tell the human the `claim_url` immediately.** A fresh account is *provisional*:
it works right away and then **expires 30 minutes after signup**, taking every
page on it with it — the URLs stop serving and start returning 410. Opening that
URL in a browser is the only thing that makes it permanent, and it only works
*before* the 30 minutes are up; after that, claiming returns 410 and the only
way forward is a fresh signup.

So say it once, plainly, in the same message as the published link: *this page
goes away in 30 minutes unless you open* `claim_url`. Do not bury it, and do not
save it for later.

### Joining a shared account

Sometimes the human already has an account and wants your pages on it. Then they
hand you an **invite code** rather than a token. Redeem it once, instead of
signing up:

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

```json
{"token":"oa_sk_...","account":{"handle":"acct_k3f9qa"},
 "scopes":"artifacts:write,artifacts:read",
 "hint":"This token publishes to a shared account..."}
```

Save `token` exactly as above — shown once. Everything in this skill then works
unchanged, with one difference that matters: **the account is not yours.** Its
pages are visible to everyone on it, your publishes spend *its* free allowance
and bill *its* balance, and an admin can revoke your token at any time.

So name the account whenever you hand over a URL — *published to `acct_k3f9qa`
(shared account)* — and check with the human before doing anything that costs
money on it. There is no `claim_url` to chase: a shared account is already
claimed, and nothing you publish there expires.

Refusals are ordinary `{code, message, hint}` 4xx bodies — an unknown, revoked,
expired or used-up code each says which. A **403** is the one worth reading out:
it means the account's owner turned agent joining off, and the way in is a token
they mint for you (dashboard → Team) rather than a code. The same code also
works for a human: `$OA_API_URL/invite/<code>` is a page they open in a browser
to join themselves.

## 2. Publish

One page:

```bash
curl -sX POST "$OA_API_URL/v1/publish" \
  -H "Authorization: Bearer $OA_TOKEN" \
  -F slug=snake-game \
  --form-string html='<!doctype html><meta charset="utf-8"><title>Snake</title><h1>Snake</h1>'
```

A site — each part's filename is its path inside the site:

```bash
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' \
  -F 'files=@app.js;filename=js/app.js'
```

Link assets **relatively** in the HTML (`href="css/app.css"`, not
`href="/css/app.css"`): the site is served from a subpath, so an absolute path
escapes it.

Response:

```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":"..."}}
```

**Give the human `url`.** That is the entire point — a link they can open and
send to anyone. Do not paraphrase it or shorten it.

Rules: `slug` matches `^[a-z0-9-]{3,40}$` (omit it and one is generated).
Max 10 MB and 50 files per publish.

## 3. Update, roll back, inspect

**Update = publish the same slug again.** Same URL, new version, no new link to
hand out:

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

```bash
# what exists, and which version is live
curl -s "$OA_API_URL/v1/artifacts" -H "Authorization: Bearer $OA_TOKEN"
curl -s "$OA_API_URL/v1/artifacts/my-site" -H "Authorization: Bearer $OA_TOKEN"

# roll back: make version 1 live again (rolling forward is the same call)
curl -sX POST "$OA_API_URL/v1/artifacts/my-site/activate" \
  -H "Authorization: Bearer $OA_TOKEN" -H "Content-Type: application/json" \
  -d '{"version": 1}'

# quota, credit, and the claim link while provisional
curl -s "$OA_API_URL/v1/account" -H "Authorization: Bearer $OA_TOKEN"

# this month: publishes used, storage, views, private pages, balance
curl -s "$OA_API_URL/v1/usage" -H "Authorization: Bearer $OA_TOKEN"
```

## 4. When a call fails

Every error is `{code, message, hint}`. The `hint` names the exact next call.
Read it and follow it instead of guessing.

- **401** — no usable token. Go back to step 1.
- **403 `account_expired`** — the provisional account passed its 30 minutes.
  Reads still work, writes do not, and it is too late to claim: sign up again
  (step 1) and republish.
- **410** — on `POST /claim/:code`, the account expired before anyone opened the
  link; on a published URL, the page's account expired. Same answer: sign up
  again.
- **402 `payment_required`** — the free publishes are used up, or the balance
  went negative. The body says what it costs and how to pay:

```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"}],
 "hint":"..."}
```

**Tell the human the amount and get their agreement before paying.** Then do
what `accepts[0].instructions` says — resend the same publish with the header,
and it settles and publishes in one call:

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

The amount in the header must equal `amount_usd` exactly — read it from the
body, never assume it. It is $0.005 for an ordinary publish, but if the monthly
metering pass has driven the balance negative it is the arrears **plus** this
publish, because that is the sum that actually clears the request. Paying per
call gets tedious, so for a run of publishes buy credit once and retry with no
header — the balance covers each one:

```bash
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}'
```

Top-ups are $1–100; a real card rail starts at $5. (`dev` is the local
settlement rail: instant, and no real money moves. On a real instance the
`accepts` array names a real rail — same handshake, real money.)

## What things cost

| | |
| --- | --- |
| Free publishes, provisional account | 5, for its whole 30-minute life |
| Free publishes, claimed account | 100 per UTC calendar month, resetting on the 1st |
| Publish past the allowance | $0.005 |
| Private or unlisted artifact | $0.50 per artifact **per month** |
| Storage | $0.05 per GB-month |
| Traffic | $1.00 per million page views, first 1,000,000/month free |

The last three are charged by a monthly metering pass rather than at request
time, so a balance can go negative — and while it is, a publish answers 402
asking for a top-up. `GET /v1/usage` shows the month so far: publishes used and
free left, stored GB, views, private artifacts, and the balance.

## Notes

- Publishes are scanned, and `moderation_status` on the artifact says where a
  page stands. A form asking for a seed phrase, a private key, or a card number
  with its CVV is `blocked`: that URL serves a 451 page instead of the content.
  A password field next to a brand name or an urgency lure ("verify your…") is
  flagged `review` — it keeps serving, but a human sees it in the moderation
  queue and can take it down. Do not publish login pages that imitate a real
  brand.
- `visibility` is `public` by default. `PATCH /v1/artifacts/<slug>`
  `{"visibility":"unlisted"|"private"}` changes it and costs $0.50 a month for
  as long as it stays off public — the PATCH pays the first month, the metering
  pass charges the rest. `private` responses include a `signed_url` with the
  `?sig=` a viewer needs.
- Going the other way — letting someone else into an account you can already
  manage — is `POST /v1/account/invites` (`{"role":"member","max_uses":1,`
  `"ttl_hours":72}`, or the `create_invite` MCP tool). It returns an
  `invite_url` for a human and a `code` for an agent.
  `GET /v1/account/members` answers "who publishes here": the people and the
  live tokens, side by side.
- There is also a CLI (`oa deploy ./site`) and an MCP endpoint at `POST /mcp`
  with the same tools. Use curl unless the user asks for those.

Full endpoint reference, including tokens, reports and the MCP tool list:

```bash
curl -s "$OA_API_URL/skill/reference.md"
```
