# Publish — share a workflow's HTML to a public URL
Status: **Design** (2026-06-24). Not implemented.
## Goal
A workflow already produces HTML artifacts — the **Pulse log** (`builder/improve.html`)
and the **reporting dashboard** (`reports/`). We want to **publish them to a public URL**
on any static host (Netlify, Vercel, Cloudflare Pages, GitHub Pages, S3, a VPS, …), driven
by the **builder agent** — exactly the way Backup is agent-driven.
## Publish = Backup's twin
Same shape, pointed at public hosting instead of durable storage. The Backup scaffold
(`workflow_backup.go`, the Backup popup, the toolbar status dot, `backup-strategy.md`) is
the template; Publish mirrors each piece.
| | **Backup** (exists) | **Publish** (new) |
|---|---|---|
| Goal | "can I get it back?" — durable storage | "can I share it?" — a public URL |
| Config | `workflow.json.backup` | `workflow.json.publish` |
| Status | `backup/status.json` | `publish/status.json` (incl. the live **URL**) |
| Endpoint | `handleRunWorkflowBackup` | `handleRunWorkflowPublish` (mirror it) |
| Reference doc | `backup-strategy.md` | **`publish-strategy.md`** |
| UI | Backup popup + toolbar dot | **Publish popup + toolbar button (shows URL + status dot)** |
| Auth | creds / secret | a provider token via the secrets system |
| Agent prompt | `buildWorkflowBackupAgentPrompt` | `buildWorkflowPublishAgentPrompt` |
The builder reads `workflow.json.publish` + `read_skill(skills=[{"name":"builder-reference","path":"references/publish-strategy.md"}])`,
deploys the HTML, and writes `publish/status.json` with the public URL.
## Provider-agnostic by construction (agentic, no per-provider Go)
The whole point: **we do not enumerate providers in Go.** Adding a host is a doc edit, not
a release — same philosophy as Backup (whose `backup-strategy.md` covers "any large-file
backend" via a matrix + `rclone` as the catch-all, with zero per-backend Go).
- Go stays generic: `workflow.json.publish` carries a **free-form `provider` string**, the
deploy details, an auth secret name, and the resulting URL. Go never knows what "Netlify"
is.
- `publish-strategy.md` holds the knowledge — a **general static-deploy method** + specifics
for common hosts.
- The agent has shell + the user's token, so it runs whatever the host needs and reads back
the URL.
### The "any static host" method — three universal paths
The doc gives the agent these, and it picks whichever the named provider supports:
1. **Provider CLI** — `netlify deploy`, `vercel`, `wrangler pages deploy`, `gh-pages`,
`surge`, `firebase deploy`, …
2. **Git-push-to-deploy** — push the static files to a repo the host auto-builds
(Netlify / Vercel / Pages / Render). Reuses the git skill Backup already uses.
3. **Object-store / file sync** — `aws s3 sync` / `rclone` / `rsync` to a static bucket or a
server+nginx. The true catch-all: any host that serves files from a bucket or directory.
Between these, "any provider with static hosting" is covered with no provider-specific code.
New host you've never configured? Set it up conversationally with the builder
("publish to my Cloudflare Pages project X") → it writes the config and deploys.
## Publishing the dashboard (the wrinkle)
The two artifacts differ:
- **Pulse log** (`improve.html`) — self-contained HTML. Publishes as-is. ✅
- **Reporting dashboard** (`reports/`) — **live**: it calls `window.report.query(sql)`
against `db/db.sqlite` *inside the app*. Static hosting has neither the bridge nor the DB,
so it would render empty.
**Decided (2026-06-24): Option A — static HTML generation (snapshot).** The agent bakes the
dashboard to static HTML at publish time; no live DB, no client-side DB. B and C below are
recorded for context but are **not in scope** (sql.js may be a future opt-in if interactivity
is ever needed).
- **A. Snapshot / bake → static HTML (the approach).** At publish time the agent runs every
query the dashboard uses, inlines the results as JSON, and injects a shim so the page reads
baked data:
```js
window.__REPORT_DATA__ = { "": [...rows] }
window.report = { query: (sql) => window.__REPORT_DATA__[normalize(sql)] ?? [] }
```
Fully static, works on any host. Snapshot-as-of-publish — **auto-republish after each run**
keeps it fresh. Also the **privacy-safest**: only the baked query results are exposed, not
the whole DB. Handles the common case (a fixed set of queries).
- **B. sql.js in the browser** *(out of scope — future opt-in).* Ship `db.sqlite` + sql.js
(SQLite→WASM) for a genuinely interactive dashboard, at the cost of exposing the whole DB.
- **C. Serverless function** *(out of scope).* A Netlify/Vercel function queries the DB to
keep data server-side; not pure static, most setup.
The snapshot is an agentic transform the agent performs at publish time per
`publish-strategy.md` — read the report's queries, run them against `db.sqlite`, inline the
results + shim, deploy the static bundle.
## Data privacy (must be explicit in the flow)
**Publishing a dashboard publishes its data.** Snapshot or sql.js, whatever's in the queries/
DB goes to a public URL. So:
- `workflow.json.publish` should let you **scope what's published** (which artifacts; for the
dashboard, which views/queries, or a sanitized/filtered DB).
- `publish-strategy.md` makes the agent **warn before exposing raw rows** and confirm the
scope, especially for sql.js (whole-DB) publishes.
## Config shape — `workflow.json.publish`
Generic and open-ended (no provider enum):
```json
{
"enabled": true,
"mode": "agent",
"targets": ["pulse", "report"], // which HTML artifacts to publish
"dashboard_mode": "snapshot", // snapshot (static HTML) — the decided approach
"destinations": [
{
"id": "main-site",
"provider": "",
"method": "cli|git|sync", // which universal path
"site": "",
"secret_name": "",
"public_base_url": ""
}
],
"triggers": ["manual", "post_run"]
}
```
## Status — `publish/status.json`
```json
{
"version": 1,
"state": "not_configured | configured_not_verified | publishing | published | stale | failed",
"url": "",
"last_published_at": "",
"last_attempt_at": "",
"last_source_hash": "",
"destinations": [ { "id": "...", "provider": "...", "url": "...", "state": "...", "error": "" } ],
"last_error": "",
"updated_at": ""
}
```
`stale` reuses Backup's source-hash trick: the artifacts changed since the last publish, so
the public URL is behind.
## Backend (mirror `workflow_backup.go`)
- `handleRunWorkflowPublish` — accepts `action: "publish" | "configure"` (+ later `unpublish`).
Spawns a builder session with the publish prompt; writes `publish/status.json`.
- `buildWorkflowPublishAgentPrompt` — the agentic contract: read `workflow.json.publish` +
`read_skill(skills=[{"name":"builder-reference","path":"references/publish-strategy.md"}])`; for each destination, prepare the static
artifacts (bake the dashboard per `dashboard_mode`), deploy via the destination's method,
capture the URL, write status. Confirm scope before exposing data.
- Route under `/workflow/publish`, `/workflow/publish/run`, `/workflow/publish/config`.
- `publish-strategy.md` registered in `guidance.go` (Modes: workshop/run — it's a workflow
builder capability, like backup-strategy).
## UI (mirror the Backup popup + toolbar)
- **`WorkflowPublishPopup.tsx`** — public URL (with copy/open), per-destination status,
"Publish now" / "Set up", scope (which artifacts), the configured destinations, and an
illustrative "common hosts" hint list (suggestions, not a gate). Reuse `backupStatus.ts`
patterns for the state→visual mapping.
- **Toolbar button** — a "Publish" button (Globe/Upload icon) with a status dot
(`published` green, `stale` amber, `failed` red, `not_configured` grey) and the URL in the
tooltip. Sits next to the Backup button.
- AI-driven only (no manual config editor), consistent with Backup.
## Setup & verify first (same as Backup)
Publishing follows Backup's set-up-then-verify flow — the user configures and **tests it
manually before anything runs unattended**:
1. **Configure** (`action: "configure"`) — the builder sets up `workflow.json.publish` with
the provider/destination/token. State → `configured_not_verified`. Nothing is public yet.
2. **Test publish** (manual "Publish now") — the first real deploy. The agent publishes, gets
a working URL back, and confirms it loads. State → `published` (verified).
3. **Then auto-republish is allowed.** Until a destination has had at least one successful
(verified) publish, the post-run trigger does **not** fire for it — so we never push an
unproven config to a public URL unattended. This matches Backup's
`configured_not_verified` → `healthy` gate.
## Triggers (decided 2026-06-24)
Publish rides the **same cron-driven post-run Pulse pass as Backup** — it's one more step in
the post-run steward, not a separate hook:
> triage → fix → back up → **publish** → notify
- **Auto (cron):** on a scheduled run, if publish is **on** (and verified, and artifacts
changed), the Pulse pass publishes — so the public dashboard/Pulse log stay current with no
manual action. Gated three ways: publish **on**, destination **verified** (a prior
successful manual publish), and **source-hash changed** (skip an unchanged artifact set).
- **Manual:** if publish is **off** (or for an unscheduled workflow), it's a manual "Publish
now" via the builder — exactly like Backup's manual run. The manual publish is also the
verification step above.
So: **publish on + cron → auto-publish via Pulse; otherwise → manual via the builder**, the
same on/off-then-manual shape Backup has.
## Org Pulse publishes too (same mechanism)
Publish isn't workflow-only. The **Chief of Staff's Org Pulse** produces its own HTML
(`pulse/org-pulse.html` — the org heartbeat log), and it can be published the same way so the
whole org's status is shareable at a public URL.
- Same **provider-agnostic agentic mechanism** — one `publish-strategy.md` serves both; only
the **source HTML** and **config location** differ.
- **Workflow publish:** config in `workflow.json.publish`; targets `improve.html` + `reports/`;
runs in the workflow Pulse post-run pass.
- **Org publish:** config in the CoS config (`multiagent-config.json`); target
`pulse/org-pulse.html`; runs as a step in the **Org Pulse** daily pass (`org-pulse.md`),
same on/verified/changed gating.
- Same setup→verify→auto-via-cron flow on both.
So the Publish build should treat the "what HTML + where's the config" as parameters, not bake
in "workflow." Reference doc and deploy logic are shared; the two Pulse passes each call it
with their own artifact + config.
## Build plan
1. **Backend scaffold** — ✅ Done (2026-06-24). `WorkflowPublishConfig`/`WorkflowPublishDestination`
in `workflow_manifest.go`; `workflow_publish.go` (status types, `publish/status.json`,
source-hash over improve.html + reports/ + db.sqlite, `handleGetWorkflowPublish` /
`handleUpdateWorkflowPublishConfig` / `handleRunWorkflowPublish` with `configure`/`publish`
actions, `buildWorkflowPublishAgentPrompt`); routes `/workflow/publish[/config|/run]`.
Mirrors `workflow_backup.go`. Build + vet clean. **Manual publish path works end-to-end.**
2. **`publish-strategy.md`** — ✅ Done (2026-06-24). The agentic playbook, registered as
`read_skill(skills=[{"name":"builder-reference","path":"references/publish-strategy.md"}])` (workshop/run/multi-agent). 3 universal deploy
paths + static-snapshot procedure + privacy/scope + setup→verify + status contract. Serves
both workflow and org publish.
3. **UI** — ✅ Done (2026-06-24). `publishStatus.ts` (state→visual + dot), `WorkflowPublishPopup.tsx`
(state header, prominent **public URL** with open/copy, Publish now/Set up, destinations,
common-hosts hint), toolbar **Publish** button (Globe + status dot) in `WorkflowToolbar.tsx`,
API methods + types. tsc clean.
4. **Auto-republish via Pulse** — ✅ Done (2026-06-24). Added **step 4 PUBLISH** to the
post-run Pulse pass (`scheduler.go` `runPostRunMonitor` is now 5 steps:
triage → fix → back up → **publish** → notify) and a matching **4b** section in
`post-run-monitor.md`: re-publish only an already-**verified** destination, only when
artifacts changed; never the first unattended publish.
5. **Org publish** — ✅ Done (2026-06-24). Added **step 6** to `org-pulse.md`: publish
`pulse/org-pulse.html` per `publish-strategy.md`, same verified+changed gate, opt-in via a
CoS `publish` config / `pulse/publish.json`. Fully agentic (no new Go) — the Org Pulse
agent deploys via shell per the shared reference doc.
## Decided (all 2026-06-24)
- **Dashboard:** **Option A — static HTML generation (snapshot)**. The agent bakes query
results into static HTML at publish time. sql.js/serverless out of scope.
- **Provider model:** provider-agnostic + agentic — no per-provider Go; `publish-strategy.md`
holds the 3 universal deploy paths.
- **Trigger:** **manual + auto-republish**, both from v1. Auto-republish is a **step in the
cron-driven Pulse post-run pass** (triage → fix → back up → publish → notify), gated on
publish-on + verified + source-hash-changed. Off → manual via the builder, like Backup.
- **Setup/verify:** configure → `configured_not_verified` → manual test publish → `published`;
auto only after verified (same as Backup).
- **URL:** **host's default URL** (e.g. `*.netlify.app`, `*.pages.dev`) for v1; custom
domains deferred.
*(No open questions — design is settled; ready to build.)*
## Non-goals
- No per-provider Go adapters — providers live in the reference doc.
- No manual JSON config editor (AI-driven, like Backup).
- Not a general web host / CMS — only deploying the workflow's existing HTML artifacts.