# Getting a key and publishing your first artifact You are reading this because you are an AI agent that has been pointed at an **Artifacts and Handoffs** server. This page is everything you need to get from "no credentials" to "a human has the link and I can read their answers". The full endpoint reference is [api.md](api.md). ## What this service does It hosts small HTML pages ("artifacts") and forms for you, and it handles the people part: - You upload a version of an artifact (`index.html` plus any CSS, JS, images and fonts it needs) and get a URL on the artifact host. - You **finalize** that version with a list of recipients (directly, or as saved groups). The service mails every recipient their own personal link, calls any group webhook, and hands you the links it sent. - You can attach a **form**, one or more pages of fields, and later fetch the answers as JSON or CSV. A form can be rendered by the service or designed by you inside an artifact. - Everything lives under a **project**, and every write is written to an audit log that the humans who own the account can read in the admin UI. There are two hosts. The API and the admin UI are on the api host, for example `https://api.artifactsandhandoffs.com`. Artifacts and forms are served to people from the artifact host, one subdomain per account: `https://demo.artifactsandhandoffs.link/...`. You only ever call the api host; the service builds the artifact URLs for you. In the examples below, use the API host configured for your deployment, and keep the key in a `$KEY` variable: it is shown to you exactly once. ## Step 1: register a key ```bash curl -sS -X POST https://api.artifactsandhandoffs.com/system/register \ -H 'Content-Type: application/json' \ -d '{"name": "Release notes agent", "email": "you@example.se"}' ``` `name` is required (max 100 characters) and is what humans see next to your key in the audit log. `email` is optional; when you give one, the person who approves the key can be told which address asked. The answer is `201`: ```json { "key": "3f9c... (64 hex characters)", "claim_url": "https://api.artifactsandhandoffs.com/claim/3b1f...", "expires_at": "2026-09-12T14:18:05+00:00" } ``` If the server answers `403 registration_disabled`, self-registration is switched off on this deployment: ask your human to create a key for you in `/admin/keys` instead. Registration is rate limited per client address (`429 rate_limited`). ## Step 2: show the claim URL to your human and wait Print the `claim_url` and ask the person you are working for to open it and approve your key. Do not try to approve it yourself: the page asks them to sign in by email, and the whole point is that a human decides which account you get to write to. The claim URL stops working at `expires_at` (15 minutes); if it lapses, register again. If you registered with the `email` of someone who is already a member of an account, an owner of that account can also approve the key from the admin UI's Keys page within the same 15 minutes, without opening the link. While you wait, poll `/v1/me`: ```bash curl -sS https://api.artifactsandhandoffs.com/v1/me -H "Authorization: Bearer $KEY" ``` Until the key is claimed this answers `403` with `{"error": "key_pending", "message": "The key has not been claimed yet. Open the claim URL."}`. Poll every 5 to 10 seconds, and give up after a few minutes rather than hammering the server. Once a human has approved it you get `200`: ```json { "account": {"id": "8e2...", "slug": "demo", "name": "Demo", "quota": {"used": 0, "max": 104857600}}, "key": {"id": "b71...", "name": "Release notes agent", "status": "active", "last_used_at": null} } ``` `account.slug` is the subdomain your artifacts will live on. `quota` is in bytes; an upload that would take you past `max` answers `413 quota_exceeded`. Send `Authorization: Bearer $KEY` on every call from here on. `key.scope` in that answer says what you are allowed to do. `full` is the usual one: publish and change artifacts and forms. `artifacts` leaves forms read-only, and `read` refuses every write. A call outside your scope answers `403 key_scope` with a message saying so; reading is never refused by a scope. A human chooses the scope when they create the key, so if you need more than you have, ask them rather than retrying. ## Step 3: publish an artifact **Create a project** (a folder for related work; reuse an existing one with `GET /v1/projects`): ```bash curl -sS -X POST https://api.artifactsandhandoffs.com/v1/projects \ -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{"name": "Website", "description": "Public site"}' ``` The answer carries `id` and a `slug` derived from the name (`website`). Either one works wherever a project is named. **Create the artifact**: ```bash curl -sS -X POST https://api.artifactsandhandoffs.com/v1/artifacts \ -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{"project": "website", "title": "Launch plan", "votes": "1-5"}' ``` Defaults worth knowing: `visibility` is `private` and `access` is `token`, so only the personal links you hand out below can open it. `access: "shared"` instead answers once with a `shared_key` you can append to the public URL as `?key=...`, which is useful when you want one link for a chat channel. `votes` (`off`, `thumbs`, `1-5`, `0-10`) turns on the built-in rating; `hits_enabled` and `info_enabled` (both true) let the page read its own view count and version list. `expires_in_days` overrides the server default (30 days), counted from the newest version. The answer includes `id`, `url` (the public artifact URL) and `upload_url`. **Upload the files.** This is the one call that is `multipart/form-data`, not JSON. Every file is stored under the filename you give it, and that filename is its relative path inside the version, so use `;filename=` for anything in a subdirectory. `index.html` is required: ```bash curl -sS -X POST https://api.artifactsandhandoffs.com/v1/artifacts/$ARTIFACT/versions \ -H "Authorization: Bearer $KEY" \ -F 'files[]=@index.html' \ -F 'files[]=@site.css;filename=css/site.css' \ -F 'files[]=@logo.png;filename=img/logo.png' ``` The field name does not matter (`files[]` is just a convention). Allowed extensions are `html`, `htm`, `css`, `js`, `mjs`, `json`, `map`, `txt`, `svg`, `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico`, `woff`, `woff2`, `ttf`, `otf`, and the bytes have to match the extension: a script renamed to `.png` answers `422 file_not_allowed`. There is no server-side code and no upload from the page itself; an artifact is static files in a sandboxed iframe. The answer is the new version: ```json {"number": 1, "size_bytes": 20481, "created_at": "2026-09-12T14:20:02+00:00", "preview_url": "https://demo.artifactsandhandoffs.link/7c1.../preview/1/9ab.../"} ``` Open the `preview_url` (valid one hour, not counted as a view) to check your work. Uploading again creates version 2; recipients keep seeing the newest *finalized* version until you finalize the new one. **Finalize and send.** The first call to finalize locks the version and sends the mail: ```bash curl -sS -X POST https://api.artifactsandhandoffs.com/v1/artifacts/$ARTIFACT/versions/1/finalize \ -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{"recipients": [{"name": "Anna", "email": "anna@example.se"}, {"email": "erik@example.se"}], "groups": []}' ``` `groups` takes ids from `GET /v1/groups` (a saved list of people, optionally with a webhook); a group must be global or linked to this project, otherwise you get `422 group_not_in_project`. The answer is the only place you ever see the personal links, so keep them if you need them: ```json {"version": 1, "url": "https://demo.artifactsandhandoffs.link/7c1.../", "links": [{"name": "Anna", "email": "anna@example.se", "url": "https://demo.artifactsandhandoffs.link/system/view/4d2..."}], "notified": {"mails": 2, "failed": 0, "webhooks": []}} ``` A group created with `members_hidden` is not listed back to you: its people are mailed and counted in `notified.mails` and in `hidden_recipients`, but their names, addresses and personal links never appear in `links`, in `GET /v1/artifacts/{id}/recipients`, in a form's submissions or CSV, in `per_recipient` votes, or in the group's webhook payload (which carries `recipient_count` instead). The humans who own the account see all of it in the admin UI; you are the one it is hidden from, so do not build on knowing who is in such a group. `notified.mails` and `notified.failed` count the mails, and `notified.webhooks` lists one `{group, status}` entry per group webhook that was called, with the HTTP status it answered (`0` when the call itself failed). A webhook URL has to resolve to public addresses: one pointing at a private or loopback address is refused with `422 validation` when the group is saved, and never called. A second finalize of the same version answers `409 already_finalized`. Later, `GET /v1/artifacts/{id}/recipients` tells you who has opened their link, and `GET /v1/artifacts/{id}/votes` gives you the ratings. ## Step 4: ask people something with a form Create the form with a schema. Every field type and validation rule is in `docs/forms.md`: ```bash curl -sS -X POST https://api.artifactsandhandoffs.com/v1/forms \ -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{ "project": "website", "title": "Launch feedback", "schema": { "pages": [ {"title": "About you", "fields": [ {"name": "email", "type": "email", "label": "Email", "required": true}, {"name": "role", "type": "select", "label": "Role", "options": ["Developer", "Designer", "Other"]} ]}, {"title": "Feedback", "fields": [ {"name": "nps", "type": "nps", "label": "Would you recommend us?"}, {"name": "areas", "type": "matrix", "label": "How satisfied are you with", "rows": ["Documentation", "Support"], "scale": {"min": 1, "max": 5}} ]} ], "thanks": "Thank you." } }' ``` An invalid schema answers `422 invalid_schema` with an `errors` list naming each problem, for example `pages[0].fields[1].options: must be unique`. The answer includes the form `id` and its `url` on the artifact host, where the service renders it one page at a time in plain HTML. Set `artifact_id` instead if you want to design the page yourself; the form URL then redirects to that artifact, and your HTML posts to `/system/form/{id}` (see `docs/forms.md`). Publishing works exactly like finalizing an artifact, and is also once only: ```bash curl -sS -X POST https://api.artifactsandhandoffs.com/v1/forms/$FORM/publish \ -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \ -d '{"recipients": [{"name": "Anna", "email": "anna@example.se"}]}' ``` You may edit the schema until the first answer arrives; after that `PATCH /v1/forms/{id}` answers `409 schema_locked`. By default an answer needs a personal link and each recipient may answer once; `anonymous_allowed` and `multiple_allowed` relax that. Set `send_copy` and each recipient is mailed a copy of their own answers when they submit (only to the address you invited them on). Read the answers whenever you like: ```bash curl -sS "https://api.artifactsandhandoffs.com/v1/forms/$FORM/submissions" -H "Authorization: Bearer $KEY" curl -sS "https://api.artifactsandhandoffs.com/v1/forms/$FORM/submissions.csv" \ -H "Authorization: Bearer $KEY" -o answers.csv ``` Each item is `{id, submitted_at, recipient_name, recipient_email, data}`, oldest first, with `data` keyed by field name. To poll for new answers only, pass the last `submitted_at` you saw back as `?since=2026-09-12T14:31:07.482913+00:00`; it has microsecond precision exactly so that this is safe. ## Security model in two sentences Each account gets its own browser origin (`{account}.artifactsandhandoffs.link`), so one account's artifacts can never read another account's cookies or storage, while the artifacts of a single account share that origin and are only as separated from each other as the iframe sandbox makes them. In path-mode deployments (`artifactsandhandoffs.link/{account}/...`, used where wildcard DNS or TLS is unavailable) every account shares one origin, so treat anything you publish there as readable by any other artifact on that server.