Publishing Artifacts

Your agents can put a file on the web and hand back a link. They write to their artifacts directory, call artifact_publish, and get back a URL that renders markdown and HTML as pages and serves everything else for the reader’s device to resolve. Every link carries a readable name derived from the title (…/a/q3-revenue-report), opens only for you unless you say otherwise, and can carry a lifetime (expiresInHours).

Your brain also receives the artifact-publishing skill automatically — it is an engine system skill, mirrored into skills/engine/artifact-publishing/ by omnislash upgrade (see brain-dev-guide.md § Updating Engine). It teaches agents when to publish vs send a file, and the self-containment rules for published HTML.

A published page is read, not used: it cannot call back into your brain. For a page the person interacts with — an exercise, a quiz, a form, a live view your agent updates — open a widget instead (widgets.md). The two meet twice: an HTML publication opens as an ad-hoc widget (widget_create({ file: 'artifact:<id>' })), and a widget shows published files as its media (audio, images — URLs that work only while the widget is open). Media made for a page (voice_render({ widget }), image_generate({ widget })) and a page not written yet (widget_create({ draft })) are the widget’s own — never publish a file only to show it on, or test, a widget. Widget pages run on the same content-only origin as your publications.

What your agents get

Registered automatically:

ToolArgsReturns
artifact_publishpath, title?, replaces?, alias?, expiresInHours?, visibility?{ id, url, filename, contentType, sizeBytes, alias, expiresAt, visibility }
artifact_unpublishid{ id, revoked }
artifact_liststatus?[], visibility?[], publisher?[], agentId?, q?, cursor?, limit?{ artifacts, nextCursor, totalCount, counts, publishing } — every publication, expired and revoked included
artifact_getidthe row with status, publicUrl and its trail (published / replaced / meta edited / revoked, who, which session)
artifact_read_fileidthe content (utf-8 text or base64), without it past 1 MB (truncated)
artifact_updateid, title?, expiresInHours? (0 = expire now), visibility?: 'private'the row; making one public or renaming it (alias) is refused toward artifact_widen
artifact_widensame, plus alias and visibility: 'public'the row — artifact:widen, a person approves
artifact_deleteid{ id } — the record goes and its name is freed; artifact:widen, a person approves

artifact_publish, artifact_unpublish and artifact_update need artifact:publish; the reads runtime:read; artifact_widen and artifact_delete artifact:widen (granted to no role by default) — each can put a link already handed out in front of anyone: public is world-readable, and a freed name can be claimed by a new public publication.

Every agent’s system prompt already names its artifacts directory ($ARTIFACTS_ROOT/agents/<agent-id>/), and that is where artifact_publish reads from. Nothing to declare — write the file, publish the path.

agent writes report.md → artifact_publish({ path, title: 'Q3 Revenue Report' })
                       → https://artifacts.yourdomain.com/a/q3-revenue-report
       pinned name     → artifact_publish({ path, alias: 'weekly-report' })
                       → https://artifacts.yourdomain.com/a/weekly-report

The name comes from your fast model chain, given the title (or the filename when there is no title). No model, slow model, broken model — the link still appears, named after the file. So pass title: it is what the URL reads like.

Requirements

A public origin must be configured. With PUBLIC_DOMAIN set, links serve from artifacts.<your-domain> automatically — give that subdomain an A record like the others (a wildcard covers it). To use a different origin (a vanity domain, a tunnel), set ARTIFACTS_PUBLIC_URL explicitly. Deployments with only WEBHOOKS_PUBLIC_URL (IP mode) fall back to serving links from that origin.

When none of those is set, the two tools are not registered at all, so an agent never sees a tool that would promise a URL nobody can open.

If you opted the webhooks service out via defineProject({ services }), publishing is off. That is the intended way to disable it.

Access model — read this before enabling

Published links are private (admin login) by default. Opening one asks for your admin login (ADMIN_USER / ADMIN_PASS_HASH, the same Basic-auth pair that guards the dashboard). The browser prompts once per origin; after that every link just works, and stays a 401 for anyone else.

That default is what pays for the readable URL. The name in the link is guessable — that is what makes it a name — so the access control cannot be the link. It is your login.

visibility: 'public' makes a link world-readable. Use it for something meant to be shared: anyone with the URL can read it, and since the URL is a readable name, anyone who guesses the name can too. Rules:

  • You cannot pass visibility together with replaces: a publication keeps its audience across content updates. Changing it afterwards is its own edit: artifact_update makes a link private; making one public after the fact is artifact_widen (artifact:widen, restricted) — an agent’s call waits for your approval, since the link may already be in chats and inboxes. On the dashboard the same switch is your edit. The flip takes effect on the next request.
  • Flipping a link to private needs the admin pair. Without ADMIN_USER/ADMIN_PASS_HASH there is nothing for it to authenticate against, so every surface refuses the private direction outright rather than handing you a link that 401s for you too.
  • Want world-readable but not guessable? Publish 'public' and hand out the /p/<id> URL instead of the name. The id (pub_ + a cuid2) stays unguessable; that is now its whole job.
  • No admin credentials configured? Then nothing can gate a private link, so the default falls back to 'public' — every publication is world-readable. omnislash doctor warns about this, and the deployment logs it at startup. Set both vars if that is not what you want.

Names are how links read. A publication is always reachable at /a/<name>, and the name is derived from your title unless you pin one with alias. Rules:

  • Shape: lowercase letters, digits, inner hyphens, max 63 chars. A name you pass is rejected if it does not match, not normalized.
  • A pinned name must be free — unlike a derived one it is never silently suffixed, because you are about to paste that exact link somewhere. A taken name fails the publish; the fix is another name, or replaces on the current holder’s id. Derived names get -2, -3, … instead.
  • You cannot pass alias together with replaces: a content update never moves the name. A rename is its own edit, artifact_widen({ id, alias }) (an agent’s call waits for your approval), recorded on the action log with who made it. It changes the public URL and frees the old name.
  • A name stays with its publication permanently. Revoking or letting it expire does not hand the name back, so a link that once pointed at something never starts pointing at something else. Deleting the record (artifact_delete) is what puts a name back in circulation — which is why it and a rename wait on a person: once freed, a new public publication may claim the name, and the link already handed out serves that to anyone.
  • The name is only an address. Updating and revoking still go by id.

Expiry is silent. expiresInHours: 24 makes the link stop serving a day after publish — the same 404 as a revoked link, with no warning page. Null (the default) means permanent. To extend a living document, pass expiresInHours together with replaces, or artifact_update({ id, expiresInHours }) — the clock re-counts from that moment, and null makes it permanent. An expired publication cannot be replaced (mint a new link); extending it with artifact_update serves it again, and it can be revoked.

Revocation is real and immediate: artifact_unpublish(id) makes the URL 404. The row survives for the audit trail, but nothing serves from it again — and a revoked publication cannot be resurrected by republishing over its id.

Revoking, replacing, editing and deleting are scoped by id, not by publisher. Any agent holding artifact:publish and the id can revoke a publication or point it at new bytes (and, with your approval on artifact:widen, rename or delete it), whichever agent originally published it. Since the id is also a public handle, that includes an agent that simply read the link somewhere. This is intentional — it is what lets you or another agent kill a link an agent published — but if your brain treats one agent’s publications as off-limits to another, the engine will not enforce that for you. The engine records who published, who replaced and who revoked — and the action log has a row for every edit, widen, revoke and delete; it does not adjudicate between them. A brain that wants a person on these sets hitl: true on artifact:publish.

One limit on that openness: a replace cannot change the file’s extension. The extension picks the renderer, so without this rule anyone holding a link to a rendered report.md could turn that same URL into an arbitrary HTML page on your domain. Content may change; the kind of document a URL serves may not. To publish a different format, mint a new link.

Published pages are sandboxed: they render (inline CSS, inline JS, data-URI images all work) but cannot make any network request, so a generated page can never phone home with what it renders.

Requiring approval on every publish

The artifact:publish capability ships hitl: false — the agent chooses the file, the name and the lifetime, but never the destination or (by default) the audience, and the act is revocable. If your brain wants a human confirmation card before each publish — most plausibly because your agents publish 'public' routinely — override it:

// constitution/engine-capabilities.json
{
  "artifact": {
    "publish": { "hitl": true }
  }
}

The same file is where you raise or lower risk. See the capabilities section of contract.md.

Living documents

Pass replaces to update a publication in place, keeping its URL:

artifact_publish({ path: 'status.md', alias: 'status' })       → { id: 'clx8k2m…', url: …/a/status }
… later …
artifact_publish({ path: 'status.md', replaces: 'clx8k2m…' })  → same URLs, new content

The bare status.md resolves against the agent’s own artifacts directory.

Use this for anything a person bookmarks — a status page, a rolling report. Without replaces every publish mints a new URL, and the old one keeps serving the old bytes. The replacement must keep the same extension (see above), keeps the name, and re-stamps the expiry only when expiresInHours is passed again.

Blob lifetime is managed by the engine. The bytes behind a ref live for as long as a live engine object references them — a publication row, a run deliverable, a stored task artifact, a pipeline step output, a chat attachment — plus a grace window (blobGc.graceHours in omnislash.config.ts, default 72h). Superseded and unreferenced blobs are garbage-collected on that clock, so replaces does not accumulate disk forever. Two consequences for brain code: do NOT stash raw blob paths in your own brain tables and expect them to keep resolving — the engine does not scan brain tables for references; and a deleted publication (as opposed to a revoked one) releases its bytes on the next sweep.

What can be published

  • Source: a file in the agent’s own artifacts directory — or, for an agent running a workflow task, that run’s directory. A bare filename resolves against those. Anything else is refused, including other agents’ files and the engine’s internal blob store: that is what keeps artifact_publish from becoming “put anything the system ever stored on the internet”. file_send follows the same rule for its file_path.
  • Size: up to 25 MB. Larger files are refused at publish time.
  • Snapshot semantics: the bytes are copied into blob storage at publish time. Your agent can keep rewriting the working file, or lose it entirely — the link keeps serving what was published.

Rendering

The agent publishesThe reader gets
report.mdrendered HTML page (raw HTML inside the markdown is escaped)
dashboard.htmlthe page exactly as authored — inline CSS/JS, data-URI images and images published on the same origin all work
data.csv, plot.png, deck.pdfthe raw file with the right content type; the browser or OS decides

Add /raw to any URL to force a download instead (https://…/p/<id>/raw, https://…/a/<alias>/raw).

The tab carries your mark: the artifacts origin serves /favicon.ico from assets/favicon.* in your brain (engine placeholder if you ship none), the same file the dashboard uses. Nothing to configure — drop the file in and redeploy.

Tell your agents which one you want in their prompt: a self-contained .html file gives you charts and layout; a .md file gives you clean readable text with no styling decisions to make.

Screens from Sixty Seconds Studio, a fictional demo studio, running on the real dashboard.