# artifact-hub > Publish a file from an agent and get a public URL that renders in a browser. Any file > type, served inline, in one HTTP request. No SDK, no client library. artifact-hub hosts files generated by AI assistants — HTML, PDF, PNG, SVG, MP3, MP4, CSV, JSON, anything — and serves each one at https://.artifacthub.link, rendered inline rather than downloaded. Authentication is a bearer API key. Republishing in the same context updates the same URL instead of creating a second one. ## Links - [OpenAPI 3.1 spec](https://api.artifacthub.link/openapi.json): every endpoint, machine-readable - [This file](https://api.artifacthub.link/llms.txt): the behavior summary you are reading Those are the only two links, because they are the only two pages that exist. There is no docs site and no dashboard yet, and an endpoint that is not described below does not exist — it answers 404, not "not implemented". ## Install Nothing to install. This is plain HTTP and the only tool used below is curl. You need an API key, and only the human who owns the account can issue one. Ask them for it and have them put it in the environment: ``` export ARTIFACT_HUB_TOKEN="ah_live_..." # the human sets this, not the agent ``` Every command below runs as written once that variable is set. Nothing else needs substituting. ## Common workflows ### Publish a file and get a URL ``` curl -sS --fail-with-body -T report.html -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" https://api.artifacthub.link/v1/report.html ``` Prints one line and nothing else — the URL, with a trailing newline: ``` https://quiet-harbor-3k9m2xq7wp4v8ntb5rjz6yfd0s.artifacthub.link/ ``` So the whole thing composes: ``` URL=$(curl -sS --fail-with-body -T report.html -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" https://api.artifacthub.link/v1/report.html) ``` Use `--fail-with-body`, not `-f`. Both make curl exit non-zero on a 4xx; only `--fail-with-body` still prints the body, and the body is the sentence that tells you what to change. Plain `-s` alone exits 0 on a 400 and writes the error text into `$URL`, which is how an agent ends up reporting an error message as a link. **The path is `/v1/`.** `PUT` at the host root — `https://api.artifacthub.link/report.html` — is answered by the static-asset layer with a bare 405 and never reaches the API. You may write the URL as `https://api.artifacthub.link/v1/` and let `curl -T` append the local basename, but **only when the URL carries no query string**. With any option below, curl appends nothing, the request arrives with no filename, and you get a 400. Write the name out whenever there is a `?`. ### Republish and keep the same URL Pass the same context id both times. The link does not change; the version increments. Identical bytes in the same context are deduplicated — same URL, same version number, and `X-Artifact-Deduplicated: true`. ``` curl -sS --fail-with-body -T report.html -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" "https://api.artifacthub.link/v1/report.html?context-id=$CLAUDE_SESSION_ID" ``` Without a context id, every upload creates a new artifact and a new URL. Nothing is inferred from the API key or the source address, because guessing would silently merge unrelated uploads into one artifact's history. ### Get the full metadata instead of just the URL Ask for JSON by name. `Accept: */*` — curl's default — is not a request for JSON. ``` curl -sS --fail-with-body -T report.html -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" -H "Accept: application/json" https://api.artifacthub.link/v1/report.html ``` ```json {"id":"quiet-harbor-3k9m2xq7wp4v8ntb5rjz6yfd0s","url":"https://quiet-harbor-3k9m2xq7wp4v8ntb5rjz6yfd0s.artifacthub.link/","version":1,"filename":"report.html","content_type":"text/html; charset=utf-8","size":48219,"sha256":"9c1185a5c5e9fc54612808977ee8f548b2258d31a3b1f2c0e6d4a7b8c9d0e1f2","deduplicated":false,"context_id":null,"expires_at":"2026-09-03T10:12:33Z","created_at":"2026-08-04T10:12:33Z"} ``` ### Publish generated content without writing a file ``` curl -sS --fail-with-body https://api.artifacthub.link/v1/artifacts \ -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" \ -H "Content-Type: application/json" \ -d '{"filename":"summary.html","content":"

Done

","expires":"7d"}' ``` This route answers JSON by default. Use `content` for UTF-8 text or `content_base64` for binary, never both, and keep the decoded size under 25 MB — the body is parsed whole, in memory. **The field names here are snake_case (`context_id`, `new_link`) while the PUT query string above is hyphenated (`context-id`, `new-link`).** That asymmetry is real. The wrong spelling is rejected with a 400 rather than ignored, so you find out immediately instead of getting a second link you did not ask for. ### File it in a folder Add `path`. The folders are created as needed (`mkdir -p`), names fold on case and accents, and the first spelling wins. ``` curl -sS --fail-with-body -T report.html -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" "https://api.artifacthub.link/v1/report.html?path=reports/2026" ``` `path` is one word, so it is spelled the same in the query string and in a JSON body — it is the one option with no hyphen/underscore asymmetry to get wrong. It needs only `artifacts:write`. **A `path` on a republication is ignored.** The folder is set when an artifact is born; a new version does not move it, and nothing is created. To refile an artifact, send `folder_id` to `PATCH https://api.artifacthub.link/v1/artifacts/{id}`. ### Work with the folders themselves Publishing with `path` covers most of it, and four routes exist for the rest. All of them speak folder **ids**, except the create, which speaks a path from the root. ``` # Create, or resolve one that already exists. Idempotent: an existing folder is # returned rather than duplicated, matched on the folded name. curl -sS --fail-with-body -X POST -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" \ -H "Content-Type: application/json" -d '{"path":"reports/2026"}' https://api.artifacthub.link/v1/folders # One level down: the subfolders, and the artifacts filed at that level. curl -sS --fail-with-body -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" \ "https://api.artifacthub.link/v1/folders/{folder_id}/children?limit=50" # Rename. The display name only; the fold key is derived, never sent. curl -sS --fail-with-body -X PATCH -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" \ -H "Content-Type: application/json" -d '{"name":"Relatórios"}' https://api.artifacthub.link/v1/folders/{folder_id} # Reparent. `null` is the root, and it is REQUIRED — an absent field is a 400, # not "move it to the root". The two readings differ by the whole tree. curl -sS --fail-with-body -X POST -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" \ -H "Content-Type: application/json" -d '{"parent_folder_id":null}' https://api.artifacthub.link/v1/folders/{folder_id}/move ``` `cursor` and `limit` on `children` page the ARTIFACTS at that level and nothing else — subfolders come back whole on every page. There is no `q` and no `sort` here: they are not implemented, and an accepted-but-ignored `q` would answer 200 with every artifact in the folder under a heading claiming otherwise. Search across folders is `GET https://api.artifacthub.link/v1/artifacts`, which does take both. Moving a folder onto itself or into its own descendant is a 409. Renaming into a name a live sibling already holds is a 409 as well; trashing a folder releases its name, so the collision can also appear on a restore. ### Set an expiry `12h`, `7d`, `30d` (the default), or `never`. Anything else is a 400 that names the allowed values. ``` curl -sS --fail-with-body -T slides.pdf -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" "https://api.artifacthub.link/v1/slides.pdf?expires=7d" ``` Say the expiry when you hand over the link. A link that silently dies is worse than one that was never made. ### Upload a large file (over 90 MB, up to 5 GB) Three steps: mint a ticket, PUT the bytes straight to storage, then complete. Needs `jq`. ``` TICKET=$(curl -sS --fail-with-body https://api.artifacthub.link/v1/uploads \ -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" -H "Content-Type: application/json" \ -d "{\"filename\":\"demo.mp4\",\"content_type\":\"video/mp4\",\"size\":$(wc -c < demo.mp4),\"sha256\":\"$(shasum -a 256 demo.mp4 | cut -d' ' -f1)\"}") curl -sS --fail-with-body -T demo.mp4 -H "Content-Type: video/mp4" "$(echo "$TICKET" | jq -r .url)" curl -sS --fail-with-body -X POST -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" "$(echo "$TICKET" | jq -r .complete.url)" ``` Four things decide whether this works: - **The second call goes to a different host.** The ticket's `url` points at the storage service, not at https://api.artifacthub.link. Send it exactly as given and do not rebuild it. - **Send the ticket's `headers` verbatim**, `Content-Type` included. They are part of what the signature covers, and an added or altered header is a bare 403 with no body. Send no `Authorization` header on that request: the signed URL carries its own. - **Send `sha256`.** With the digest known up front the ticket points straight at the final storage key and `complete` is a metadata write. Without it the object has to be copied server-side afterwards — up to 5 GB of rewrite for the sake of one local `shasum`. - **`size` is required** and is checked before a URL is issued, so an oversized file is refused before the transfer rather than after it. `max_bytes` in the ticket is advisory: a presigned PUT cannot enforce a size cap. The real limit is applied at `complete`, which rejects an oversized object and deletes it. `complete` returns the same artifact JSON as every other create path, always with status 200 — it is idempotent, so a retry after a timeout answers identically to the call that timed out. If `POST /v1/uploads` answers **503** naming unset variables, presigned uploads are not configured on that deployment. That is a designed state, not an outage: relay the message and use `PUT /v1/` for anything under 90 MB. ### Take a published link offline Set it private. The URL stops serving on the very next request, and nothing is deleted: ``` curl -sS --fail-with-body -X PATCH https://api.artifacthub.link/v1/artifacts/$ID \ -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" -H "Content-Type: application/json" \ -d '{"visibility":"private"}' ``` `$ID` is the `id` field of the create response — the first label of the hostname, not the whole URL. Setting `"visibility":"public"` puts it back. **Prefer this over deleting.** It is reversible; a delete becomes permanent after 30 days. The same call edits `title` and `expires_at`. Every field is optional, at least one is required, and anything else is a 400 rather than a silently ignored field — a misspelled `visibilty` that was dropped would answer 200 with the file still public. `expires_at` is **unix seconds, or `null` for never**, and `null` is not the same as leaving the field out: omitting it keeps the current expiry, sending `null` clears it. A time in the past is accepted and takes the link down immediately, which is the reversible way to kill a link now. There is no `filename` field. The filename belongs to a version and versions are immutable, so publishing under a new name is a new version: `PUT /v1/` with `?update=$ID`. ### Find and remove what you published ``` curl -sS --fail-with-body -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" https://api.artifacthub.link/v1/artifacts curl -sS --fail-with-body -X DELETE -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" https://api.artifacthub.link/v1/artifacts/$ID ``` The list is newest first and paginates by cursor: pass the previous response's `next_cursor` back as `?cursor=`. It ends when `next_cursor` is `null`. Three parameters narrow and reorder it, and they combine. Use them to find an id instead of listing every page — and never guess an id or rebuild one from a URL. ``` curl -sS --fail-with-body -H "Authorization: Bearer $ARTIFACT_HUB_TOKEN" \ "https://api.artifacthub.link/v1/artifacts?q=invoice&sort=name&folder_id=root&limit=20" ``` - `q` searches the title and the current version's filename together, folding case and accents — `relatorios` finds `Relatórios`, the same way a folder path does. - `sort` is `created` (the default), `updated`, `published` or `name`. The first three are newest first; `name` is A→Z by **title**, not by filename. A cursor belongs to one ordering, so changing `sort` mid-listing is a 400 — start again. - `folder_id` takes a folder's id, or `root` for the top level. Leaving it out lists every folder, which is a different question from `folder_id=root`. It is not recursive. `limit` bounds the page. Anything else in the query string is a 400 rather than an ignored filter: `?status=` and `?context_id=` are documented in older material and are refused, because a dropped filter hands you a list you believe is narrower than it is. Deleting answers 204 and is idempotent. The link answers 410 immediately; the bytes are purged 30 days later and there is no undelete. A key can be issued that publishes and edits but cannot delete, so a `DELETE` may be refused with a 403 on a key whose `PATCH` works. That is a permission, not a bug — relay it to the human who issued the key. ## Notes for agents - **Never write the token's value into a command, a file, a log line, or a message.** Reference it as `$ARTIFACT_HUB_TOKEN` and let the shell expand it. If you can see the token's characters in something you are about to emit, stop and rewrite it. - **Publishing is public-by-URL.** Anyone with the link can read the file, and links get pasted into chats that unfurl them. Never publish `.env` files, credentials, private keys, tokens, customer data, or anything the human has not seen. - **Ask before publishing something you were not asked to publish.** Ask once. If the answer is no, or there is no answer, do not ask again for that file. - **Reuse the context id** so that iterating on a document updates one link instead of producing five. Use `$CLAUDE_SESSION_ID` when it is set. - **Report the URL exactly as returned.** Never reconstruct, shorten, or guess a URL, and never present a URL that was not in a successful response. - **Say the expiry when you hand over a link.** The default is 30 days, and a link that silently dies is worse than one that was never made. - **Do not retry a 4xx.** Every 4xx here means something has to change first; the message says what. Retry only `429` and `503`, and only after `Retry-After`. - **One upload per file.** Republishing the same bytes in the same context is free and returns the same URL, so a retry after a timeout is safe. A retry with no context id creates a second artifact. - **The filename decides how the file is served, not what the URL looks like.** It never appears in the public URL. Send the real name including its extension; the served content type is recomputed from the bytes and the name, and the `Content-Type` you send is recorded but never trusted. ## Error handling Every error returns the same JSON envelope, and every response — success or failure — carries an `X-Request-Id` header holding the same value as `request_id`. That is the one string to quote to a human. ```json {"error":{"type":"payload_too_large","message":"File is 143 MB; the direct upload limit is 90 MB. Request a presigned URL with POST /v1/uploads and PUT the file there — that path accepts up to 5 GB. Do not retry this call against the same endpoint.","request_id":"req_01K1XQ8Z4Y7N3M2P6R9T5V0BWC","docs":"https://api.artifacthub.link/docs/errors#payload_too_large"}} ``` The `docs` field points at a documentation site that does not exist yet, so it will not help you. Read `message`: the remediation is written into it, including the endpoint or flag to switch to. `PUT /v1/` is the one exception to the JSON rule: because it answers `text/plain` on success, it answers `text/plain` on failure too — the same `message`, with a trailing newline — unless you asked for JSON by name. Read the message; it names the fix. | Status | `error.type` | What to do | |---|---|---| | 400 | `invalid_request` | Fix the request as the message describes and send it once more. Do not resend it unchanged. Query parameters on a PUT are hyphenated; JSON body fields are snake_case, and the wrong spelling is rejected rather than ignored. | | 401 | `missing_credentials` | No token was sent. Check that ARTIFACT_HUB_TOKEN is set in the environment. Do not ask the user to paste a token into the chat, and never place one on a command line. | | 401 | `invalid_token` | The token is malformed or unknown. Tell the human to check the value or to issue a new key. Stop; do not retry. | | 401 | `token_revoked` | The key was revoked. Only the account owner can issue a new one. Tell the human, stop, and wait. | | 401 | `token_expired` | The key is past its expiry. Same remedy: the human issues a new key. Stop and wait. | | 403 | `insufficient_scope` | The key is valid but lacks the scope named in the message. The account owner adds it. Do not retry, and do not try a different endpoint to work around it. | | 403 | `csrf_blocked` | A browser-style cookie request was rejected. An agent should not see this: send Authorization: Bearer and no cookie. Do not retry the cookie form. | | 403 | `account_disabled` | Nothing an agent can do. Report it to the human and stop. | | 404 | `not_found` | The artifact does not exist, or this key cannot see it. Do not guess ids. This is also the answer for a path that is not in this file, so check the path first. | | 409 | `conflict` | Something you named is taken, or an upload's checksum did not match what was stored. Choose a different name, or upload the file again. | | 410 | `gone` | The artifact was deleted or expired. It is not coming back. Publish a new one if the human wants a live link. | | 413 | `payload_too_large` | The file is over the limit for this path. Use POST /v1/uploads for anything above 90 MB, up to 5 GB. Sending Content-Length matters: a body with no declared length is capped at 25 MB because it has to be held in memory. | | 415 | `unsupported_media_type` | The body encoding is not accepted here. POST /v1/artifacts takes application/json or multipart/form-data; raw bytes go to PUT /v1/. | | 416 | `range_not_satisfiable` | The Range header asked for bytes outside the object. Drop the header and request the whole file. | | 422 | `unprocessable_entity` | The request parsed but is not valid. The message names the field. Correct it and send once more. | | 429 | `rate_limited` | Wait for the number of seconds in Retry-After, then retry once. Do not retry in a loop. | | 429 | `quota_exceeded` | Storage or artifact quota is full. Waiting does not help — there is no Retry-After. Delete an artifact or ask the owner to raise the quota. | | 500 | `internal_error` | Ours. Retry once after a few seconds. If it fails again, report the request_id to the human and stop. | | 503 | `service_unavailable` | Temporary, or a capability this deployment has not been configured with — the message says which. Retry after Retry-After, at most twice, then stop and relay the message. | If a call fails, relay the error field and do not claim success.