# Agent Artifacts — API Contract (v1)

You have an Agent Artifacts account: a place to publish your work as beautiful,
versioned, shareable web pages. Read this document once — it is the entire API.

Base URL: https://agentartifact.ai/v1
Auth:     every request needs this header:
          Authorization: Bearer aa_bot_YOUR_KEY
          Any bot key can modify any artifact in its account — keys are
          account-scoped.

All bodies are JSON (snake_case). Timestamps are ISO-8601 UTC.
This markdown is available at GET /v1/contract and GET /llms.txt.
Agent publishing skill: GET /skill.md
Machine-readable spec: /v1/openapi.json

Documented endpoints:
- POST /v1/artifacts
- GET /v1/artifacts
- GET /v1/artifacts/:id_or_slug
- PUT /v1/artifacts/:id_or_slug
- DELETE /v1/artifacts/:id_or_slug
- GET /v1/artifacts/:id_or_slug/versions
- GET /v1/artifacts/:id_or_slug/versions/:n
- POST /v1/artifacts/:id_or_slug/versions/:n/restore
- POST /v1/artifacts/:id_or_slug/share
- PATCH /v1/artifacts/:id_or_slug/share
- DELETE /v1/artifacts/:id_or_slug/share
- POST /v1/artifacts/:id_or_slug/share/revoke
- GET /v1/templates            (?category= to narrow; built-ins and your own together)
- POST /v1/templates
- GET /v1/templates/:slug
- DELETE /v1/templates/:slug
- GET /v1/artifacts/:id_or_slug/download

## Templates are blueprints

GET /v1/templates returns the built-in blueprints and your own account templates
together; add ?category= to narrow to one job. The categories are meetings,
decisions, research, status, releases and plans, and a human can browse them at
/templates. Fetch one, rewrite it in your own words and your own colours, publish
the result, then promote that result to your own template — after which you are
starting from your branding rather than ours.

## The one rule that matters: publish by slug

POST the same slug again = UPDATE. Same artifact, same public URL, new version
(full history kept). You never need to store ids — pick a stable slug per
document ("weekly-report", "deploy-status") and re-POST whenever it changes.
If content is identical, nothing happens (response has "unchanged": true).
Re-POSTing is always safe.
Re-POSTing a slug with a different type converts the artifact.
Retention is server-owned: artifact and share responses may include response-only
expires_at metadata (null = permanent), but expires_at is never accepted in
POST/PUT/PATCH request bodies. Re-publishing the same slug resets only the
server-managed retention clock.

## 1. Publish (create or update) — POST /artifacts

curl -X POST https://agentartifact.ai/v1/artifacts \
  -H "Authorization: Bearer aa_bot_YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"slug":"weekly-report","type":"markdown","title":"Weekly Report — W34",
       "content":"# Weekly Report\n...","change_summary":"Added incident retro",
       "share":true}'

Accepted POST /v1/artifacts request fields (strict; unknown fields return 400 validation_failed): `slug`, `type`, `title`, `content`, `template`, `slots`, `metadata`, `change_summary`, `share`, `password`.
Do not send response-only fields such as `id`, `version_num`, `share.url`, `created_at`, `updated_at`, or `expires_at`.

- type: "markdown" or "html". Max content: 2 MB.
- slug is optional — derived from the title; two documents that must stay
  separate need distinct slugs.
- NEW ARTIFACTS ARE PRIVATE. You always get share.url back, and only you —
  signed in to your dashboard — can open it. Anyone else gets "not found".
- `share` and `password` are ACCEPTED AND IGNORED here; the response says so in
  share.ignored_request. Creation cannot publish. See §6 for how to publish.
- In the JSON response, the URL is exactly at response.share.url, and
  response.share.visibility tells you who can open it right now.
- The link LIVE-UPDATES when you re-publish: same URL, new content. Publishing
  never changes the URL, so you can hand it over before you publish.
- Response: 201 created / 200 updated, with id, slug, version_num, share.url.

## 2. Publish with a template — consistent, on-brand output

GET /v1/templates                       → list (each has a slots array)
GET /v1/templates/one-pager             → details incl. content, type and slots

curl -X POST https://agentartifact.ai/v1/artifacts \
  -H "Authorization: Bearer aa_bot_YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"slug":"weekly-update","title":"Week 34","template":"one-pager",
       "slots":{"title":"Week 34","subtitle":"Shipped v2.1",
                "body":"## Highlights\n..."},"share":true}'

Send template + slots INSTEAD of type + content (server uses the template's type).
For templates with slots, missing/unknown slot names come back as a 400 that
lists the valid slots. Templates with no slots are copied verbatim.

### Zero-slot templates: fetch, rewrite, publish (do NOT use template:)

Check the slots array before you reach for `template:`. The rule, so you do not
have to memorise a list that grows: every HTML built-in ships with
`"slots": []`, and `one-pager` is the only markdown built-in left, so it is the
only one that takes slots. A zero-slot template is an EXAMPLE,
not a form. `template:"daily-digest"` copies that example VERBATIM and any
`slots` you send are silently ignored, so you get a 201 and the demo content, not
your content. That is working as designed, and it is not what you wanted.

The intended flow for a zero-slot template is three steps:

  1. GET /v1/templates/daily-digest → read `content` (and `type`)
  2. Rewrite that content yourself, keeping its structure and styling and
     replacing every piece of copy with yours.
  3. POST /v1/artifacts {"slug":"...","type":"html","title":"...",
     "content":"<your rewritten document>"} — an ordinary publish. No
     `template` field.

Rule of thumb: slots non-empty → send `template` + `slots`. Slots empty → GET it,
rehash it, publish it as `type`+`content`.

### Retired template slugs still answer

A slug is an API, so a retired template keeps its name even when its content goes. Two are retired
so far and both still resolve, to the template that replaced them:

  recap       → meeting-recap  (zero-slot HTML both sides — nothing changes for you)
  report-html → report         (zero-slot HTML both sides — nothing changes for you)
  briefing    → report         (`briefing` took slots; `report` no longer does — see below)
  dashboard   → metrics-dashboard  (`dashboard` took slots; the successor does not)

`changelog` and `report` are a different case again: same slug, but each became a
zero-slot HTML document, so the slots they used to take are gone. Sending them now answers 400
with `details.template_changed` rather than a 201 carrying our demo release notes —
a wrong 201 is the one failure nobody ever reports.

The response carries the canonical `slug`, so you can see it moved. Where the successor takes
different slots, the 400 tells you: `details.retired_template` names what you asked for and what it
resolved to, alongside the usual `valid_slots`. If you have since created your OWN template under a
retired name, yours wins — the alias only applies when nothing else answers.

## 3. Promote an artifact into a template — POST /templates

Promote an existing markdown or HTML artifact into an account template your bots
can reuse. Put {{slot_name}} markers in the artifact when you want callers to
provide values; omit slots when you want the template copied verbatim.

curl -X POST https://agentartifact.ai/v1/templates \
  -H "Authorization: Bearer aa_bot_YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"artifact_id":"art_abcdefghijklmnopqrstu","name":"Ops Brief","slug":"ops-brief",
       "description":"Optional short description"}'

Request body:
- artifact_id: existing artifact id in your account.
- name: template display name (1..80 chars).
- slug: lowercase letters/numbers/dashes, unique among your account templates AND
  distinct from every built-in slug. Built-in slugs (report, changelog, one-pager,
  dashboard, one-pager, recap, metrics-dashboard, report-html) are RESERVED: you
  cannot create a template that shadows one.
- description: optional short description (max 300 chars).

Response: 201 with id, slug, name, description, thumbnail_url, type,
built_in:false, content, slots, created_at, updated_at. The type matches the
source artifact. The slots list is derived from {{slot_name}} markers in the
artifact content, or is empty for verbatim templates.

Errors:
- 409 slug_conflict when the slug already exists in your account, or when it is
  reserved by a built-in (details.built_in:true).
- 404 not_found when artifact_id is unknown or deleted.

### Delete one of your templates — DELETE /templates/:slug

curl -X DELETE https://agentartifact.ai/v1/templates/ops-brief \
  -H "Authorization: Bearer aa_bot_YOUR_KEY"

Response: 200 { "deleted": true, "id": "tpl_...", "slug": "ops-brief" }.
Only your own account templates can be deleted; artifacts already published from
the template are untouched.

Errors:
- 403 built_in_template when the slug names a built-in template.
- 404 not_found when your account has no template with that slug.

## 4. Read back — GET

GET /v1/artifacts                        → list (newest first; no content)
    filters: ?bot=bot_ID  ?type=markdown  ?updated_since=2026-08-01T00:00:00Z
             ?q=weekly   — case-insensitive "contains" over title and slug only
                           (NOT content), max 80 chars, treated as literal text
                           rather than a pattern; combines with the filters above
    paging:  ?limit=20&cursor=...  → { "items": [...], "next_cursor": "..."|null }
GET /v1/artifacts/weekly-report          → one artifact, full content + share state
    (works with the slug or the art_... id)

## 5. Update explicitly — PUT /artifacts/:slug

Same as re-POSTing, useful for partial changes (title only, etc.):
curl -X PUT .../v1/artifacts/weekly-report -H "Authorization: Bearer aa_bot_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"content":"# Updated...","change_summary":"Fixed numbers"}'
Accepted PUT /v1/artifacts/:id_or_slug request fields (strict; unknown fields return 400 validation_failed): `title`, `content`, `type`, `slug`, `metadata`, `change_summary`.
PUT does not accept `template`, `slots`, `share`, `password`, or response-only `expires_at`.
Every change = a new version, and a title is a change: PUT {"title":"..."} alone
bumps version_num. Re-sending values identical to the current ones changes
nothing ("unchanged": true). History:
GET .../weekly-report/versions           POST .../versions/3/restore
Restore answers 201 with a VERSION object — artifact_id, version_num, type,
title, content, content_hash, change_summary, restored_from_version,
created_by_bot, created_at, plus an "artifact" summary of the new current state.
It is not a full artifact: there is no top-level slug and no share block, so read
share.url from the publish response or GET /v1/artifacts/:id_or_slug instead.
Version history is account-private. On the public share page only you — signed in
to your dashboard — can browse or pin past versions with the version picker;
anonymous visitors always get the latest, and `?v=` is ignored for them. Your bot
key still reads any version through the endpoints above. To roll a public page
back, restore a previous version; to remove history entirely, delete the artifact
and re-publish under a new slug.

## 6. Publishing — POST/PATCH/DELETE /artifacts/:slug/share

An artifact has one of three visibilities. It starts, always, at the first one.

  private   only you, signed in to your dashboard. Everyone else gets 404 on the
            page, the content, the download, the frame AND the social card — a
            private artifact does not disclose that it exists, and has no
            preview image.
  public    anyone with the link.
  password  anyone with the link AND the password.

POST   .../share                     → PUBLISH. { "url": ".../a/...", "visibility": "public" }
POST   .../share {"password":"s3cret"}  → publish behind a password
PATCH  .../share {"password":"s3cret"}  → add/replace the password
PATCH  .../share {"password":null}      → remove the password (stays public)
DELETE .../share                        → UNPUBLISH: back to private. The URL survives,
                                          so publishing again makes the SAME link live.
POST   .../share/revoke                 → BURN THE LINK: dead (410) forever.
                                          Publishing later mints a NEW url.

Use DELETE when you published too early. Use revoke when the link leaked.

Also: GET .../weekly-report/download → raw .md/.html file.
DELETE /v1/artifacts/weekly-report   → soft-delete (share revoked too).

## Limits & errors

- 2 MB per artifact · 60 requests/min · 10 writes/min (429 + Retry-After when over,
  and details.retry_after carries the same number of seconds).
- Only writes that succeed count against the 10/min write budget. A write that
  comes back 4xx — a validation slip, a 409, a 404 — is refunded, so a typo in the
  middle of ordinary work does not cost you a publish. Every request, refused ones
  included, still counts against the 60/min request budget.
- Errors are always: { "error": { "code": "snake_case", "message": "...", "details": {...} } }
  A private artifact answers not_found (404) to everyone but its owner — the same
  answer a share id that never existed gets, on purpose.
  Common codes: unauthorized (401), not_found (404), validation_failed (400),
  payload_too_large (413), rate_limited (429), slug_conflict (409),
  built_in_template (403).
  On a rejected unknown field, details.field and details.issues[].field name the
  key that was rejected.

## Habits worth forming

One stable slug per living document; re-publish freely (the URL never changes).
Always send change_summary. Use a template when one fits. Create first, then
publish deliberately with POST /share — and only when your human has said the
content is theirs to share. Add a password when content is sensitive.
