Skip to the content.

Design Brief — One-Click Agent Actions (Catalog, Gating Design, Receipts)

Status: Draft for build review · Roadmap: Phase 1 (UPGRADE_ROADMAP.md “One-click agent actions” — last unchecked Phase 1 interaction feature; review 2026-08-24b §3 order 5/6: brief now, build after) Evidence base: market-scan-2026-08-24.md top-5 #4 (LoopX protected-action preview + receipts, Impact 7 / Effort 2, “mandatory UX” for this brief); roadmap-review-2026-08-24b.md §6 fold 3 (gating design written ONCE here — the Phase 2 NL command bar consumes it); existing machinery verified in-repo 2026-08-24: workflow-runs-api.js (create/start/cancel/pause/resume/reassign/override-failure/approvals), gateway-workflow-dispatcher-v2.js (budget gate + last_tick_summary.budgetEnforcement), lib/budget-enforcement.js, routes/budget-routes.js, governance.js (GOVERNANCE_ACTION_RULES), routes/sse-routes.js (/api/events/stream), src/shell/mutation-manager.mjs (in-flight dedupe only), src/shell/native-views/{approvals-view,workflows-view,tasks-view,publish-view,mission-control-view,audit-view}.mjs, src/agent-view.mjs (claim/release/execute/retry) Order: docs only. No .js/.mjs/.sql/.yml changes in this commit. Concurrent-lane guard: coder is building cost backfill (scripts/backfill-run-costs.js + tests) — the build phase of THIS brief must not touch those files.


1. Purpose & Value Proposition

The dashboard can show everything and do almost nothing safely. Today an operator who spots a failed run must mentally assemble a curl command or hunt through three views whose buttons all behave differently: Approvals’ single-click Approve has no preview, its red Cancel actually DELETEs the run (approvals-view.mjs .apv-delete-triggerDELETE /api/workflow-runs/:id), Workflows view lists runs with no cancel at all, and mutation-manager.mjs dedupes only in-flight clicks — a double-click that lands after the first request resolves fires twice. There is no record anywhere of who pressed what, when, with what result beyond scattered writeTaskAudit rows.

LoopX (5,049★ in <3 months, market scan 2026-08-24) ships the pattern this brief adopts: protected-action preview with typed confirmation + receipts. Paperclip’s immutable audit log raised the same expectation at 79k★ scale.

One-click actions = one governed path for every consequential operator action:

  1. An action catalog (v1: five actions) replaces ad-hoc buttons.
  2. A gating core — typed schema → preview → confirmation → idempotent execution → receipt — sits between every button and every side effect.
  3. Receipts persist per action and surface in a Recent-actions tray + the existing Audit log.
  4. The gating core is designed once: the Phase 2 NL command bar composes the same typed envelope and receives the same receipts; it never gets its own confirmation semantics (review 2026-08-24b §5: building NL first would fork this design).

Everything the gating core needs exists server-side: governance permission checks (ensureGovernancePermission on approval decisions + escalations), status-guarded run transitions (idempotent by construction), budget evaluation (checkRun), SSE fan-out, audit_log. What’s missing is the ~30% in front of them: the envelope, the idempotency key, the receipts table, and consistent UX.


2. Action Catalog v1

Five actions, prioritized by operator frequency × consequence. Each row names where the button lives today vs where it lands in the build (all placements are existing windows or shell chrome — no new windowed app, keeping the app-registry count and docs-drift surface frozen).

# Action kind Does Source views (button location) Backing endpoint(s) Governance action Severity
1 task.assign Set/change task owner agent Tasks view detail edit form (owner select exists, tasks-view.mjs #tvEditOwner); Board view card context menu (new placement) PATCH /api/tasks/:id (owner) reassign_owner LOW
2 run.dispatch Create + start a workflow run on an existing task Workflows view trigger panel (“⚡ Trigger Workflow”, create+start composed today); Task detail panel “Run workflow…” (new placement); Publish Center candidate card “Dispatch” (new placement — publish-view is read-only today) POST /api/workflow-runs + POST /api/workflow-runs/:id/start (composed inside one handler server-side — never two HTTP calls from the client) launch_workflow MEDIUM
3 approval.decide Approve (or reject) a pending approval Approvals view cards (Approve/Reject exist as bare single-click — retrofitted behind the gate) PATCH /api/approvals/:id approve / reject MEDIUM-HIGH
4 run.cancel Cancel a queued/dispatched/claimed/running run Workflows view run rows (new placement); Mission Control blocked/stale panel row action (new placement); Approvals view keeps its existing Delete but is relabeled to remove the Cancel/Delete collision (§8 R2) POST /api/workflow-runs/:id/cancel cancel_run HIGH
5 run.redispatch Re-queue a failed run (reset to queued, dispatcher picks it up) Workflows view failed-run rows (new placement); Approvals view failed state (new placement). Distinct from agent-queue Retry (POST /api/tasks/:id/retry), which stays as-is POST /api/workflow-runs/:id/override-failure with next_status:'queued' (exists, unused by any view) override_failure MEDIUM

Explicitly out of catalog v1 (exist already, unchanged): pause/resume/escalate/reassign-run endpoints, snapshot revert, import.run, system-scan run/followup. They retrofit onto the gate in v1.1 using §3 with zero new design.


3. Gating Design (the reusable core)

This section is the contract the NL command bar consumes. Written once; both UI buttons and NL compose it.

3.1 Typed action envelope

ActionEnvelope {
  actionId    UUID          // minted client-side ONCE per confirmed intent (modal open)
  kind        ENUM          // 'task.assign' | 'run.dispatch' | 'approval.decide'
                            // | 'run.cancel' | 'run.redispatch'   (registry-checked)
  targetId    TEXT          // task id | approval id | run id — validated per kind
  params      OBJECT        // kind-specific, e.g. {owner} | {template, input_payload}
                            // | {decision, notes} | {reason} | {}
  paramsHash  TEXT          // sha256(canonicalJSON(params)) — sorted keys, stable stringify
  actor       TEXT          // 'dashboard-operator' until multi-operator auth lands
  confirm     { mode, value? }  // see §3.2
}

Server-side registry (lib/action-registry.js, build phase): one entry per kind declaring {kind, targetType, paramsSchema, governanceAction, severity, confirmMode, handler}. Unknown kind, unknown target, or params failing the schema → 400 {error:'invalid_action', details} before any permission check or execution. The registry is the single place a future action (or the NL bar’s verb mapping) plugs in.

3.2 Confirmation modal pattern — severity-mapped

Mode UX When
NONE Single click; toast confirms with rollback hint LOW severity only (task.assign — reversible by re-assigning)
PREVIEW_MODAL Typed preview card (LoopX pattern): exactly what will happen, on which target, with which params, plus current budget headroom for dispatch-class actions; explicit Confirm button MEDIUM / MEDIUM-HIGH (run.dispatch, approval.decide, run.redispatch)
HOLD_CONFIRM Press-and-hold ≥1.2 s with progress ring; release early = nothing fires HIGH / irreversible (run.cancel on non-queued runs)

Picked per action severity, recorded in the registry so UI and future consumers derive behavior instead of hardcoding:

3.3 Idempotency — double-clicks and network retries never duplicate side effects

Three layers; each is testable in isolation:

  1. Client intent binding. actionId is minted when the operator opens the confirmation modal (or presses a NONE-mode button). All retries of that intent reuse the same actionId. A deliberate second execution requires opening the modal again → fresh actionId. This is the semantic difference between a retry and a repeat, encoded in one UUID.
  2. Server receipt latch. action_receipts.action_id is the PRIMARY KEY. Execution inserts the receipt in the SAME transaction as the side effect. A replayed actionId hits the unique constraint, returns the stored receipt with {duplicate:true}, and performs nothing. This covers double-clicks (second click races the first), network retries (client re-POSTs after timeout), and offline-queue flushes (mutation-manager.mjs replays carry the original actionId).
  3. paramsHash staleness guard. Replay with the same actionId but a different paramsHash409 {error:'stale_retry'} — an edited-then-resubmitted form can never masquerade as a retry of the old intent.

What idempotency does NOT do: two different actionIds with identical kind/target/params both execute. That is a legitimate repeat (e.g., dispatching the same runbook twice) and must stay possible. Documented in the tray UI copy (“executed again” badge when a duplicate-by-intent follows within 5 min).

Existing mutate({key}) in-flight dedupe stays — it collapses concurrent clicks pre-network; the receipt latch is the durable backstop.

3.4 Receipt — persisted record

action_receipts (migration 024_add_action_receipts.sql, build phase) {
  action_id     UUID PK
  kind          TEXT NOT NULL            -- catalog enum
  target_id     TEXT NOT NULL
  actor         TEXT NOT NULL DEFAULT 'dashboard-operator'
  ts            TIMESTAMPTZ DEFAULT NOW()
  outcome       TEXT CHECK IN ('executed','rejected_governance','blocked_budget',
                               'failed','duplicate')
  rollback_hint TEXT NULL                -- human-readable recovery move
  detail        JSONB DEFAULT '{}'       -- governance verdict, budget verdict,
                                         // resulting entity ids (e.g. new run_id), error text
}

3.5 Budget interplay — what the UI shows when an action trips a budget

The dispatcher already enforces budgets between candidate SELECT and markDispatched (gateway-workflow-dispatcher-v2.js dispatchQueuedRuns()): pause_new_runs holds the row queued with no attempt marked, hard_stop bulk-cancels in-flight scope rows + cancels the queued candidate, warn passes through; counts land in GET /api/workflow-runs/dispatcher/statslast_tick_summary.budgetEnforcement {held, stopped, warned}; breach events persist idempotently in budget_events (readable via GET /api/budgets/:id/ledger). Fail-open degradation is shipped behavior (enforcement OFF without PostgreSQL).

One-click actions add the pre-execution probe so the operator learns about the wall before pressing Confirm, not after the dispatcher silently holds their run:

3.6 Data contract — new vs reused

New (build phase):

Endpoint Purpose
POST /api/actions The only write path for catalog actions. Validates envelope against registry → governance check (evaluateGovernanceAction) → budget probe (dispatch-class) → execute via existing storage/api functions IN-PROCESS (never HTTP self-calls) → receipt insert + audit mirror in one transaction → emit SSE. Responses: 200 {receipt} / 200 {receipt, duplicate:true} / 409 stale_retry / structured blocks per §3.5
GET /api/actions/recent?limit=50 Tray feed. Same degradation family as budgets routes: 200 {available:false, reason:'no_database'} without PostgreSQL
SSE event action-update Emitted on /api/events/stream fan-out after every receipt insert (payload = receipt). Additive to the bridge-fed channel; clients that ignore it lose nothing (tray falls back to 30 s polling, house pattern)

Reused unchanged: every backing endpoint in §2’s table (the new route calls their internal functions directly, so raw endpoints keep working for scripts/agents — the gate is additive, not a breaking cordon), GET /api/budgets, GET /api/budgets/:id/ledger, GET /api/workflow-runs/dispatcher/stats, audit_log writes, governance helpers.

Degrade-gracefully rules (binding for build):

  1. No PostgreSQL → POST /api/actions refuses ALL actions with 200 {available:false, reason:'no_database'}-shaped refusal. Audit-first principle: no receipt persistence, no side effect — an action that executes but leaves no receipt breaks the invariant the whole design exists for. (Deliberately stricter than read-endpoint degradation; called out as D1 in §9.)
  2. Receipts table missing (migration unapplied) → same refusal, reason:'receipts_unavailable'.
  3. SSE down → tray polls GET /api/actions/recent every 30 s; no reconnect storms (reuse realtime-sync’s capped-attempt pattern conceptually, tray-side it’s just polling).
  4. Governance helper unavailable → fail CLOSED (rejected_governance receipt), never execute unprompted.
  5. Budget probe fails → hide strip, proceed (dispatcher remains the enforcement backstop — matches shipped fail-open semantics).

4. Data Model & Sources of Truth

Field/data Source Freshness
Envelope fields Client intent + registry validation Per interaction
Governance permission governance.js GOVERNANCE_ACTION_RULES + normalizeActorContext (privileged actors: system/dashboard-operator/openclaw/ops-*) Static config + org bootstrap
Run/task/approval state Existing workflow_runs / tasks / workflow_approvals tables via current handlers Live at execution
Budget headroom GET /api/budgets derived spend (migration-022 columns, TTL-cached) ≤30 s stale (shipped contract)
Dispatcher holds/stops GET /api/workflow-runs/dispatcher/stats last_tick_summary.budgetEnforcement One tick (30 s default)
Breach history budget_events via /api/budgets/:id/ledger Append-only
Receipts action_receipts (NEW, migration 024) Written once per actionId, immutable
Audit mirror audit_log (existing) Written with receipt, same transaction

Dependency note: cost-headroom display quality depends on the Phase 0 cost/token backfill landing (sparse history → understated spend → greener-than-truth strips). Same gate budget slice 2 already carries (budget-ledger.md §4/R2). Strip ships regardless; accuracy note lands with it if backfill hasn’t merged.


5. UX Flows

  1. Assign (LOW): Board card menu → “Assign to…” → pick agent → click → toast “Assigned to . Undo: re-assign." Receipt lands in tray.
  2. Dispatch (MEDIUM): Task detail → “Run workflow…” → template picker → PREVIEW_MODAL (target agent, input payload digest, budget strip, rollbackHint “Cancel run if unwanted") → Confirm → button shows spinner → toast with new run id → tray receipt → run appears in Workflows view via existing `run-updated` sync.
  3. Decide (MEDIUM-HIGH): Approvals card → Approve → PREVIEW_MODAL (what was requested by whom, note field carried over) → Confirm → existing approve path → card flips to approved state on next sync.
  4. Cancel (HIGH): Workflows run row ⛔ → HOLD_CONFIRM ring completes → cancel fires → row flips to cancelled with existing last_error rendering → tray receipt with rollbackHint “Re-dispatch via run.redispatch”.
  5. Re-dispatch (MEDIUM): Failed run row ↻ → PREVIEW_MODAL (shows last_error being cleared, reset-to-queued semantics) → Confirm → run returns to queue; if a budget is breached the §3.5 banner replaces the confirm button.
  6. Cross-links: tray receipt → click opens the target (run → Workflows deep link exists via ?runId=; approval → Approvals view; task → Tasks view param navigation — patterns all present in workflows-view.mjs P7 handler). Audit view rows for actions render through the existing table untouched.

Degradation matrix:

Condition Behavior
No database Actions refuse with structured reason; tray shows “Actions unavailable — no database” empty state; all read-only views unaffected
Migration 024 unapplied Same refusal, receipts_unavailable
SSE down Tray polls 30 s; buttons work normally
Budget endpoints available:false No budget strip; dispatcher still enforces server-side
Legacy raw endpoint used by script/agent Works unchanged, bypasses receipts (documented escape hatch, not a UI path)

6. Build Sequence & Acceptance Criteria

Three slices, DB-free tests per house pattern (tests/test-action-routes.js: fake pool, scripted results, plain assert). Docs gates per build commit: docs/api-reference-complete.md entries for the two new endpoints + SSE event; CHANGELOG ### Added; node scripts/docs-drift-check.js exit 0 (no app-registry change expected — assert explicitly since the tray is shell chrome, not a windowed app).

Slice 1 — Server core: registry, routes, receipts, idempotency. Migration 024; lib/action-registry.js; routes/action-routes.js; transactional receipt+side-effect+audit writes; budget probe wiring.

Slice 2 — Client gating module + catalog retrofit. src/shell/action-gating.mjs (envelope minting, canonical JSON + sha256, modal components, hold-confirm widget); retrofit the five catalog buttons across §2 views; relabel Approvals Cancel→Delete disambiguation.

Slice 3 — Tray, SSE, budget surfacing. Taskbar tray popover; action-update emission + client handling; workflows-view budget strip; Mission Control budget-events line (consumes /ledger only).

Success metrics (wired to the impact-measurement loop):


7. Explicit Non-Goals (v1)


8. Risks & Open Questions