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:
| Tool | Args | Returns |
|---|---|---|
artifact_publish | path, title?, replaces?, alias?, expiresInHours?, visibility? | { id, url, filename, contentType, sizeBytes, alias, expiresAt, visibility } |
artifact_unpublish | id | { id, revoked } |
artifact_list | status?[], visibility?[], publisher?[], agentId?, q?, cursor?, limit? | { artifacts, nextCursor, totalCount, counts, publishing } — every publication, expired and revoked included |
artifact_get | id | the row with status, publicUrl and its trail (published / replaced / meta edited / revoked, who, which session) |
artifact_read_file | id | the content (utf-8 text or base64), without it past 1 MB (truncated) |
artifact_update | id, title?, expiresInHours? (0 = expire now), visibility?: 'private' | the row; making one public or renaming it (alias) is refused toward artifact_widen |
artifact_widen | same, plus alias and visibility: 'public' | the row — artifact:widen, a person approves |
artifact_delete | id | { 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
visibilitytogether withreplaces: a publication keeps its audience across content updates. Changing it afterwards is its own edit:artifact_updatemakes a link private; making one public after the fact isartifact_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_HASHthere 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 doctorwarns 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
replaceson the current holder’s id. Derived names get-2,-3, … instead. - You cannot pass
aliastogether withreplaces: 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_publishfrom becoming “put anything the system ever stored on the internet”.file_sendfollows the same rule for itsfile_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 publishes | The reader gets |
|---|---|
report.md | rendered HTML page (raw HTML inside the markdown is escaped) |
dashboard.html | the page exactly as authored — inline CSS/JS, data-URI images and images published on the same origin all work |
data.csv, plot.png, deck.pdf | the 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.