Skip to content

Live Artifacts

Persistent, hosted HTML pages tied to a workspace that re-fetch data on a schedule and push updates to open browsers in real time. Use them for dashboards, news/RSS feeds, status pages, and anything else that should stay fresh without a chat loop.

Artifacts are now wired as a typed pipeline of four small parts; each part is a registered toolbox tool that the agent picks by name via discovery (no prompt extension required).

┌──────────┐ ┌────────────┐ ┌─────────┐ ┌──────────┐
│ COLLECT │──►│ TRANSFORM │──►│ WIDGET │──►│ EXPORT │
│ sources │ │ pure funcs │ │ render │ │ download │
└──────────┘ └────────────┘ └─────────┘ └──────────┘
│ │
└─── snapshot store (artifact_data_…) ───────┘

Storage tables: artifacts, artifact_versions, artifact_data_sources (kind='toolbox' + tool_id), artifact_data_snapshots, artifact_transforms, artifact_widgets, artifact_exports, artifact_share_links.

The page handler (GET /a/:slug/embed) calls buildDataBus() (sources + transforms), then renderWidgets() (every widget runs through its toolbox tool), then either splices into the template via <x-widget id="..."/> placeholders or auto-lays-out via renderDefaultLayout().

Section titled “Quick start — toolbox flow (recommended)”
// 1. Discover. The agent never invents tool ids.
artifacts_toolbox__search({ query: "github issues" })
artifacts_toolbox__describe({ id: "art_collect_http_json" })
// 2. Validate the wiring before creating anything.
artifacts_toolbox__validate({
sources: [
{ name: "issues", toolId: "art_collect_http_json",
params: { url: "https://api.github.com/repos/PatriceA/octipus/issues" },
refreshSeconds: 600 },
],
transforms: [
{ name: "by_label", toolId: "art_transform_group_count",
inputName: "issues", params: { by: "labels[].name", top: 8 } },
],
widgets: [
{ slot: "labels", toolId: "art_widget_pie_chart",
bind: { data: "by_label" } },
{ slot: "table", toolId: "art_widget_table",
bind: { rows: "issues" },
params: { columns: ["number", "title", "state"] } },
],
exports: [
{ exportId: "csv", toolId: "art_export_csv", bind: { rows: "issues" } },
],
})
// 3. Create the shell (no html_template; widgets render via default grid).
create_live_artifact({
slug: "octipus-issues",
title: "Octipus issues",
type: "dashboard",
visibility: "workspace",
sources: [
{ name: "issues", kind: "toolbox", tool_id: "art_collect_http_json",
config: { url: "https://api.github.com/repos/PatriceA/octipus/issues" },
refresh_seconds: 600 },
],
})
// 4. Attach the rest — transforms, widgets, exports.
add_artifact_transform({ artifact_id, name: "by_label",
tool_id: "art_transform_group_count", input_name: "issues",
params: { by: "labels[].name", top: 8 } })
add_artifact_widget({ artifact_id, slot: "labels",
tool_id: "art_widget_pie_chart", bind: { data: "by_label" } })
add_artifact_widget({ artifact_id, slot: "table",
tool_id: "art_widget_table", bind: { rows: "issues" },
params: { columns: ["number", "title", "state"], span: 4 } })
add_artifact_export({ artifact_id, export_id: "csv",
tool_id: "art_export_csv", bind: { rows: "issues" } })

Download: https://artifacts.<host>/a/octipus-issues/export/csv.

Discovery surface: artifacts_toolbox__list, artifacts_toolbox__search, artifacts_toolbox__describe, artifacts_toolbox__validate. All four are permission tier ALLOW — the agent can browse freely.

FamilyToolOne-liner
collectart_collect_http_jsonGET/POST JSON; optional JSONPath.
collectart_collect_http_textRaw text/HTML/XML/CSV.
collectart_collect_rssRSS / Atom → { items: [{title,link,pubDate,summary}] }.
collectart_collect_octipus_toolInvoke any registered Octipus tool by handler name.
collectart_collect_mcpCall a tool on an external MCP server.
collectart_collect_html_scrapeCSS-subset selector scrape into rows + fields.
transformart_transform_jsonpathPluck a sub-tree by dotted path.
transformart_transform_filtereq/neq/in/gt/lt/contains row filter.
transformart_transform_sortStable sort by path.
transformart_transform_top_nSlice first N rows.
transformart_transform_group_countCount by key (with [] fanout).
transformart_transform_diffadded/removed/changed vs previous snapshot.
widgetart_widget_tableHTML table; picks columns by path.
widgetart_widget_listTitle + link + summary list.
widgetart_widget_kpi_cardBig number + delta + label.
widgetart_widget_markdownSafe-subset markdown block.
widgetart_widget_json_treeCollapsible JSON viewer (debug fallback).
widgetart_widget_bar_chartCSS bar chart.
widgetart_widget_pie_chartSVG pie / donut.
widgetart_widget_heatmap2D bucket heatmap.
widgetart_widget_mermaidCaptures Mermaid source — SVG renderer ships with the bundler.
exportart_export_csvRFC4180-ish CSV.
exportart_export_jsonPretty JSON.
exportart_export_markdownMarkdown table (optional title).

See artifacts_toolbox__describe({ id }) for parameters, return shape, examples, and tips on any of them.

Every embed page request runs through the same four stages (src/api/routes/artifact-pages.ts:handleEmbed):

  1. Data bus — buildDataBus(artifactId) reads the newest snapshot per source (plus the second-newest as previousInput for diff transforms), then runs artifact_transforms rows in position order. Transform output lands under its own name in the bus. Per-transform errors do not poison the bus; the failing entry is set to null and the error message is collected for the slot’s error block. (src/core/artifacts/pipeline.ts)
  2. Widgets — renderWidgets(artifactId, bus) looks up each artifact_widgets row’s tool_id, resolves its bind_json paths against the bus, merges with params_json, and calls the widget’s execute(). Each return must be { html, css? }; non-conforming widgets render the red aw-slot-error block but never crash the page. (src/core/artifacts/widget-render.ts)
  3. Template splicing — if the version row carries an htmlTemplate, <x-widget id="<slot>"/> placeholders are replaced by each rendered widget’s HTML. If no template exists, renderDefaultLayout() emits a CSS-grid layout (.aw-grid, params.span 1–4) using the same widget HTML. Either way, renderTemplate() then resolves any {{data.<source>.…}} substitutions and {{#each …}} loops, with <script> and inline handlers stripped by sanitizeTemplate().
  4. Export downloads — GET /a/:slug/export/:exportId rebuilds the data bus fresh, resolves the export’s binds + params, and calls the export tool. The tool returns { filename, contentType, body }; the route sets Content-Disposition: attachment and streams the body. Permission check matches the artifact’s visibility (same authorizeForRequest flow as the embed page).

The toolbox itself is auto-discovered on first import via folder scan of src/core/artifacts/toolbox/{collectors,transforms,widgets,exports}/. Drop a new tool file in the right folder and it appears in the catalogue on next restart — no manual registration. The four discovery tools (artifacts_toolbox__list/search/describe/validate) are the ONLY agent-facing surface for the toolbox; widget and transform ids are referenced by name in pipeline JSON, never invoked by the LLM directly.

The original inline-kind config is still accepted for back-compat — artifacts created before the toolbox shipped keep working. Do not author new artifacts with these; use kind: "toolbox" + tool_id instead.

KindConfig exampleNotes
toolbox{ url: … } (whatever the collector takes)Set tool_id to the registered collector.
tool{ tool: "websearch__search", params: { query: "..." } }Runs as the source’s principal. Vault ACLs apply.
http{ url, method, headers?, body?, jsonpath? }headers may use ${vault.<key>} placeholders.
rss{ url }Normalized to { items: [{title, link, pubDate, summary}] }.
mcp{ server, tool, params }Routed through the MCP bridge.
skill_query{ skill, prompt }Spends model tokens. Gated by workspace rate-limit.

{{data.<source>.<dotted.path>}} for value substitution; HTML-escaped. {{#each data.<source>.items}}…{{this.field}}…{{/each}} for iteration. <script> and inline on…= handlers are stripped by the sanitizer. To ship custom JS, pass it as a bundle (see “Custom JS” below).

Three built-in templates are available out of the box for the dashboard, news, and table artifact types.

ValueWho can view
privateCreator only
workspaceAny user in the artifact’s workspace (default)
signedAuthorized workspace viewers, or anyone with a valid ?t=<token> URL
publicAnyone (no auth). Per-IP rate limit applies.

Signed share links expire (default 1h, max 30d). Revocation is immediate via DELETE /api/artifacts/:id/share-links/:linkId — checked on every read.

The embed page mints an artifact-scoped JWT (5-minute TTL, signed with a key separate from session JWT) and injects it into a <meta> tag. The SDK opens a WebSocket to the gateway and subscribes to artifact.* events. On artifact.data_updated, it re-fetches via REST and patches the DOM.

If the WS disconnects, the SDK attempts exponential-backoff reconnect and flips the embed’s data-octipus-stale attribute after 30s.

Links opened from another site may initially omit the strict session cookie. The outer page establishes a same-site entry and retries authorization; visitors without a session see a sign-in/share-link message. This entry reveals no artifact metadata, and protected embeds still return 404 without authorization. Agent links use the authenticated app route for signed/private/workspace artifacts; only public artifacts get a bare outer URL.

A page’s own JavaScript is written inline, the way a hand-made page would be: <script> blocks and onclick="…" attributes in html_template. When a version is written — by the agent tool or through the REST API (create, edit, restore), all through src/core/artifacts/version-writer.ts — the scripts and handlers are lifted out of the template into one source, built with esbuild (no imports allowed; the V1 allow-list is empty) and stored per version under the bundles directory (<artifactId>/<versionId>/bundle.js). Handlers become data-octi-h markers bound by addEventListener in the bundle, so neither 'unsafe-inline' nor 'unsafe-eval' is needed. The embed includes the built bundle inline under its exact SHA-256 CSP hash. This keeps private artifacts functional inside an opaque sandbox, where a separate bundle request would lose SameSite login cookies.

A script that does not build does not fail the write: the version is saved, renders without behaviour, and the caller gets the compiler message as a warning. An edit that changes only the CSS, or one that sends back the stored template (which no longer holds scripts), carries the previous bundle forward; a restore copies the restored version’s bundle.

The outer page and the web app frame the embed with sandbox="allow-scripts" (an opaque origin). The embed’s CSP header also carries sandbox allow-scripts, so an embed URL opened directly — in path-prefix mode it is on the app’s own origin — still runs without the app’s cookies and storage.

An interactive answer saved from the chat becomes an html artifact with network = 'none': the embed CSP allows no fetch or socket (connect-src 'none') and images only from data: and blob:, and the page carries no SDK, gateway address or viewer token — a static copy without live refresh. Its markup went through the same sanitizer rules as the chat frame (on the server), and its script runs as the version’s bundle after a shim for octi, whose methods say “not available here”. Visibility is private or workspace.

See CONFIGURATION.md for the subdomain vs path-prefix tradeoff. TL;DR: add one DNS record for artifacts.<your-host> and set ARTIFACTS_HOST env. Without it, artifacts serve at /__artifacts__/a/:slug on the main host with weaker isolation.

Subscribe to lifecycle events in-process via artifactLifecycleBus.on('artifact:data_refreshed', fn). Currently fired:

  • artifact:created
  • artifact:updated
  • artifact:data_refreshed
  • artifact:viewed (sampled 1/50)

DB-backed user hook bindings will arrive in a follow-up trigger_type enum migration.

A recurring artifact:cleanup task runs hourly and:

  • Prunes snapshots beyond the newest 50 per source
  • Deletes soft-deleted artifacts older than 30 days (cascade)
  • Removes expired share links

Edit this page in the repository.