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.
Mental model — toolbox edition
Section titled “Mental model — toolbox edition”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().
Quick start — toolbox flow (recommended)
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.
Toolbox catalogue (Phase 3 baseline)
Section titled “Toolbox catalogue (Phase 3 baseline)”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.
| Family | Tool | One-liner |
|---|---|---|
| collect | art_collect_http_json | GET/POST JSON; optional JSONPath. |
| collect | art_collect_http_text | Raw text/HTML/XML/CSV. |
| collect | art_collect_rss | RSS / Atom → { items: [{title,link,pubDate,summary}] }. |
| collect | art_collect_octipus_tool | Invoke any registered Octipus tool by handler name. |
| collect | art_collect_mcp | Call a tool on an external MCP server. |
| collect | art_collect_html_scrape | CSS-subset selector scrape into rows + fields. |
| transform | art_transform_jsonpath | Pluck a sub-tree by dotted path. |
| transform | art_transform_filter | eq/neq/in/gt/lt/contains row filter. |
| transform | art_transform_sort | Stable sort by path. |
| transform | art_transform_top_n | Slice first N rows. |
| transform | art_transform_group_count | Count by key (with [] fanout). |
| transform | art_transform_diff | added/removed/changed vs previous snapshot. |
| widget | art_widget_table | HTML table; picks columns by path. |
| widget | art_widget_list | Title + link + summary list. |
| widget | art_widget_kpi_card | Big number + delta + label. |
| widget | art_widget_markdown | Safe-subset markdown block. |
| widget | art_widget_json_tree | Collapsible JSON viewer (debug fallback). |
| widget | art_widget_bar_chart | CSS bar chart. |
| widget | art_widget_pie_chart | SVG pie / donut. |
| widget | art_widget_heatmap | 2D bucket heatmap. |
| widget | art_widget_mermaid | Captures Mermaid source — SVG renderer ships with the bundler. |
| export | art_export_csv | RFC4180-ish CSV. |
| export | art_export_json | Pretty JSON. |
| export | art_export_markdown | Markdown table (optional title). |
See artifacts_toolbox__describe({ id }) for parameters, return shape, examples,
and tips on any of them.
Render pipeline
Section titled “Render pipeline”Every embed page request runs through the same four stages
(src/api/routes/artifact-pages.ts:handleEmbed):
- Data bus —
buildDataBus(artifactId)reads the newest snapshot per source (plus the second-newest aspreviousInputfor diff transforms), then runsartifact_transformsrows inpositionorder. Transform output lands under its ownnamein the bus. Per-transform errors do not poison the bus; the failing entry is set tonulland the error message is collected for the slot’s error block. (src/core/artifacts/pipeline.ts) - Widgets —
renderWidgets(artifactId, bus)looks up eachartifact_widgetsrow’stool_id, resolves itsbind_jsonpaths against the bus, merges withparams_json, and calls the widget’sexecute(). Each return must be{ html, css? }; non-conforming widgets render the redaw-slot-errorblock but never crash the page. (src/core/artifacts/widget-render.ts) - 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.span1–4) using the same widget HTML. Either way,renderTemplate()then resolves any{{data.<source>.…}}substitutions and{{#each …}}loops, with<script>and inline handlers stripped bysanitizeTemplate(). - Export downloads —
GET /a/:slug/export/:exportIdrebuilds the data bus fresh, resolves the export’s binds + params, and calls the export tool. The tool returns{ filename, contentType, body }; the route setsContent-Disposition: attachmentand streams the body. Permission check matches the artifact’s visibility (sameauthorizeForRequestflow 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.
Source kinds (legacy)
Section titled “Source kinds (legacy)”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.
| Kind | Config example | Notes |
|---|---|---|
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. |
Templates
Section titled “Templates”{{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.
Visibility
Section titled “Visibility”| Value | Who can view |
|---|---|
private | Creator only |
workspace | Any user in the artifact’s workspace (default) |
signed | Authorized workspace viewers, or anyone with a valid ?t=<token> URL |
public | Anyone (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.
Real-time updates
Section titled “Real-time updates”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.
Custom JS bundles
Section titled “Custom JS bundles”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.
Sandboxing
Section titled “Sandboxing”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.
Saved interactive answers
Section titled “Saved interactive answers”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.
Hosting / DNS
Section titled “Hosting / DNS”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:createdartifact:updatedartifact:data_refreshedartifact:viewed(sampled 1/50)
DB-backed user hook bindings will arrive in a follow-up trigger_type
enum migration.
Cleanup
Section titled “Cleanup”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