A small, boring piece of infrastructure that lets AI agents publish a rendered page and hand back a shareable URL. This page describes the pattern so you can build or use an equivalent. Hostnames below are placeholders.

What an artifact is

An artifact is one self-contained HTML file served at a URL like https://artifacts.example.com/q3-report-1a2b3c4d5e6f7a8b.html. The agent does the build step itself (inlining React, Tailwind, a charting library, whatever), so the host needs no in-browser compiler and no app server.

The bar for “should this be an artifact”: would this be a good Claude.ai Artifact? A chart, dashboard, diagram, mockup, report, comparison table, checklist or small mini-app that a human should look at or share. If a stranger reading it would be a problem, it does not belong here. Source code you are still editing stays in your repo.

The engine

Deliberately minimal: no application, no database.

  • A folder of files.
  • A web server (we use Caddy) serving that folder on one hostname.
  • A publish CLI that copies a file into the folder under a name with a random suffix.
  • A reaper: a tiny sidecar container (Alpine plus a shell loop) that runs daily.

Files are named <slug>-<16 hex chars>.html. The hex part is 64 bits of randomness and is the access control (see security).

Sidecar files

Each artifact can have two sibling files, both hidden from the public server:

  • <name>.keep: an empty marker meaning “pinned, never expire”.
  • <name>.meta: one line of JSON with title, desc, tags and optional icon. The gallery renders cards from it.

State lives in sibling filenames, not in a database, so the whole system can be inspected with ls.

Expiry and pinning

  • Default lifetime is 30 days. The daily reaper finds files older than 30 days without a .keep sibling and archives them: it moves them to a sibling directory outside the served root. The old URL then returns an “this artifact has expired” page with HTTP 410. Nothing is deleted. Unarchiving restores the same URL.
  • Pin = permanent. Pinning creates the .keep marker. It never moves the file, so the URL does not change and links you already shared keep working. Moving pinned files into a keep/ directory is the obvious design and it is wrong, because it breaks existing links.
  • The reaper also moves an artifact’s .meta with it and excludes the sidecar suffixes from its own age scan.
  • A naive while true; do ...; sleep 86400; done loop is enough. A 30-day TTL tolerates a day of jitter. Run it as a sidecar in the same compose stack as the web server, so it is reproducible with the stack.

Capabilities

VerbWhat it does
publish <file>Copy in, print the URL. Flags: --desc, --tags, --icon, --slug, --keep, --private
update <name> <file>Overwrite in place. URL, pin, description and tags are preserved unless you pass new ones
pin / unpinCreate or remove the .keep marker
archive / unarchiveTake it out of service now (410 page) or restore it with a fresh 30 days
lsList everything with age and expiry, including archived
tagsList tags already in use, so agents reuse them instead of inventing near-duplicates
rmDelete for real, live or archived

Other pieces:

  • Gallery. A browsable index of cards, each with a description, tags and an optional emoji icon. The title comes from the file’s <title>, and the accent colour is hashed from the id, so neither is passed by hand. The CLI also injects og:title and og:description so pasted links get preview cards in chat apps.
  • Private lane (optional). A second host, gated whole-site by your SSO provider, with its own folder, archive and no gallery. --private routes every verb there. Use it for ordinary-sensitivity personal data that should not rest on an unguessable URL alone. Highly sensitive data (government IDs, full card numbers) never becomes an artifact at all.
  • Decision forms. A template page that renders a list of questions from an embedded JSON block (<script type="application/json" id="decisions">). A human taps an option per item, adds comments and submits. Answers autosave by POSTing to a webhook on your automation tool (n8n is one example), which stores them keyed by artifact id. The agent later polls the same webhook with {"op":"get","artifactId":"..."}. The webhook should only accept writes for forms actually published on the public host, and only for the real item ids and option keys. Answers are as exposed as the artifact itself, so a form can set "sync": false to offer only copy and download.

Security model

Capability URL

There is no login in front of a public artifact. The unguessable 64-bit random id is the access control. Consequences that bind every agent:

  • Never publish secrets, credentials, tokens or personal data. A leaked or forwarded URL is full read access, forever if pinned. Treat it as an unlisted link, not a vault.
  • Set Referrer-Policy: no-referrer on the vhost, so an artifact that fetches a third-party asset does not leak its own URL through the Referer header.
  • Third-party scripts can read the URL. Any script an artifact loads and executes from another origin has full page access and can read location.href. External assets are allowed (no CSP), but inline by default, which is also better for durability. Do not load runtime scripts from origins you would not trust with the URL.
  • Cookie isolation. The artifact host serves arbitrary agent-authored HTML and JS. It must not share a domain-wide cookie (Domain=.example.com) with any sensitive host. Per-host cookies are safe. Re-check this if you ever introduce a domain-wide session cookie.
  • There is no quota. The reaper bounds age, not disk. This assumes trusted publishers.

A listing of every artifact URL is worth more than any single artifact, so it should be behind SSO. The tempting design is to gate / on the same hostname as the artifacts. Do not.

If the gated listing shares an origin with the artifacts, any artifact you open while logged in can run fetch('/'). The browser attaches your session cookie automatically (HttpOnly stops JS from reading the cookie, not the browser from sending it), and the script reads back every capability URL at once. One bad artifact then leaks the entire folder. The publisher is trusted, but the content is not: agents write artifacts from web research and other ingested text, so prompt injection can influence the bytes.

The structural fix:

  • gallery.example.com is a separate hostname, gated by your SSO provider (Authentik is one example), restricted to the right group. The session cookie is host-scoped, and CORS blocks reading the cross-origin response.
  • artifacts.example.com/ (exact root path only) redirects to the gallery. Users never type the gallery hostname. The redirect makes the split invisible while keeping it structural. Artifact URLs are untouched.
  • The gallery origin must never serve a file. Directory-listing links are often relative, so a click would fetch the artifact from the authenticated origin and serve agent HTML under the session cookie, undoing the split. On the gallery host, only the root renders. Every other path redirects to the same path on the public artifacts host. Never add a file_server path to that vhost.
  • The gallery needs to see the .keep and .meta sidecars to render pin state and cards, so do not hide them there. Hide them on the public host. Fetch .meta bodies from the gallery origin, since they are as revealing as the filenames. Anything the gallery origin returns must be data the browser will not execute (JSON with nosniff is fine, HTML is not).
  • Bind the SSO application to a specific group. Without a policy binding it typically falls back to “any authenticated user”, and this listing enumerates every capability URL.

Rejected alternative: one origin plus a CSP sandbox

Serve everything from one hostname, gate /, and send Content-Security-Policy: sandbox allow-scripts on artifacts. This does close the hole: the opaque origin gets no cookies and no same-origin reads. We rejected it because:

  • It permanently removes localStorage, sessionStorage and cookies from every artifact.
  • It puts the guarantee in a header rather than in the architecture. Someone debugging “why is localStorage broken” will add allow-same-origin, and that silently reopens everything.
  • The redirect gives the same UX for free.

A narrower CSP such as connect-src 'none' is a half-guard: it misses window.open('/'), which returns a readable same-origin window that CSP does not govern.

Agent rules

  • Return the URL. The artifact is not done until the requester has the link. Lead the reply with the bare URL (do not bold it, that can break auto-linking) and one line saying what it is.
  • Keep the plumbing invisible. No CLI commands, file paths, host names or reaper details in the reply unless asked.
  • Update in place. To revise, run update. Only publish for a genuinely new artifact. Re-share the same link.
  • Describe what you publish. --desc is one sentence for someone finding it in three weeks, not the title again. Run tags first and reuse an existing tag.
  • Pin only what should outlive a month. Everything else should expire.
  • Design rules (written for people who read on a phone and skim):
    1. Start from a template: findings, status report, dashboard, chart, comparison, decision brief, decision form, checklist, roadmap, wireframe.
    2. TL;DR block first: one sentence or at most three bullets with the answer or the ask.
    3. Glanceable pages aim for about 250 visible words, with detail in collapsed <details>. Long-form docs may run longer but still lead with a TL;DR and use headings.
    4. Show, do not narrate: any number, trend or status gets a stat tile, bar, status chip or small chart.
    5. One idea per card, no paragraph over about 40 words.
    6. A real palette (at least four hues doing work), never colour alone for status.
    7. Check at 390px wide, no sideways page scroll, light and dark via prefers-color-scheme.
    8. Floor: real <title>, viewport meta, alt text, sufficient contrast.

Standing one up

  1. Folder. Create artifacts/ and a sibling artifacts-archive/ outside the served root. Create them before the web server mounts them.
  2. Public vhost. Serve artifacts/ with Caddy (or any static server) on artifacts.example.com. Hide *.keep and *.meta. Set Referrer-Policy: no-referrer. Redirect exact / to the gallery host. Return a branded 410 page for archived names. If a reverse proxy sits in front, make sure the backend vhost lists the public hostname, because a preserved Host header that matches no site block gives an empty 200.
  3. Reaper. Add an Alpine sidecar to the same compose stack that mounts both folders and loops daily: move files older than 30 days without a .keep sibling to the archive, taking their .meta along.
  4. CLI. A short shell or Python script implementing the verbs above: random id generation, sidecar writes, URL printing. Make it the only way agents publish, with a documented fallback of copying a file and writing the sidecars by hand.
  5. Gallery origin. Add gallery.example.com with a custom directory-browse template that renders .meta cards. Put your SSO provider’s forward-auth in front, bind it to a specific group, allow only the root path to render, and redirect everything else to the public host.
  6. Private lane (optional). Repeat steps 1 to 3 on a second hostname gated whole-site by SSO, with no gallery, and make the CLI’s --private flag select it.
  7. Decision forms (optional). Create a webhook workflow in your automation tool with set and get operations keyed by artifact id, validating ids and option keys against the published form. Publish the template as a pinned artifact.
  8. Write the agent guide. Give your agents the rules above, the template list and the “never publish secrets” rule. Most failures come from agents that were never told it.

Verify by publishing a test page, fetching it, pinning it, running the reaper against a back-dated copy, and confirming that the gallery origin returns a redirect for any non-root path.