Skip to content

Latest commit

 

History

History
563 lines (403 loc) · 37.2 KB

File metadata and controls

563 lines (403 loc) · 37.2 KB

Breadbox Workflows Specification

Overview

Workflows are scheduled, event-driven Claude Agent SDK runs that operate on the household's financial data via Breadbox MCP. Each workflow is a code-defined preset from a curated gallery — one click in the admin UI instantiates it, and it fires automatically from that point forward (after each bank sync, on a cron schedule, or on demand).

Workflows replaced the earlier hand-authored "Agents" product surface. Underlying data lives in the workflows table (renamed from agent_definitions) and workflow_runs (renamed from agent_runs); Go service code uses AgentDefinitionResponse / AgentRunResponse as its shared types. The UI and REST surface are entirely Workflows-branded.


Table of Contents

  1. Preset Registry Model
  2. Database Schema
  3. Governance Gates
  4. Admin UI Surfaces
  5. Route Catalog
  6. Runtime — Orchestrator and Scheduler
  7. Actor Identity and API Key Lifecycle
  8. Notification Sink
  9. REST API Surface
  10. Configuration Keys

1. Preset Registry Model

Philosophy

Presets are code-defined, not database-seeded. The catalog lives in internal/service/workflow_presets.go as a Go slice (workflowPresets). The gallery page renders directly from this slice — no DB read required to show available presets. A database row (workflows table) only exists once the household enables a preset, which calls EnableWorkflowFromPreset. This means:

  • Adding a new preset to the registry immediately appears in every deployment's gallery.
  • Removing a preset from the registry does not delete any existing instantiated workflow row; the household's history is preserved.
  • The same preset cannot be enabled twice (ErrConflict on a second enable attempt).

WorkflowPreset struct

type WorkflowPreset struct {
    Slug        string // stable identifier; also the slug of the instantiated workflow
    Name        string // user-facing title shown in the gallery
    Category    string // gallery grouping (e.g., "Categorization & Review")
    Icon        string // lucide icon name
    Description string // one-line gallery card copy

    // PromptBlocks are IDs of prompt-block files (filenames under prompts/agents/
    // sans .md) composed in order into the workflow's base prompt.
    PromptBlocks []string

    // Run configuration applied when the preset is enabled.
    ToolScope             string  // "read_only" | "read_write"
    Model                 string  // empty = DefaultAgentModel
    MaxTurns              int     // 0 = DefaultAgentMaxTurns (10)
    ScheduleCron          string  // empty = no cron; post-sync presets omit this
    TriggerOnSyncComplete bool    // fire after each successful sync

    // EstCostPerRunUSD is a rough per-run Anthropic-cost estimate surfaced
    // in the configure drawer as a transparency hint.
    EstCostPerRunUSD float64

    // Options are preset-specific configuration choices rendered as selects
    // in the configure drawer. Each chosen choice's Directive is appended
    // to the composed prompt.
    Options []WorkflowPresetOption
}

Prompt composition

Each preset declares one or more PromptBlocks — references to Markdown files under prompts/agents/ (e.g., "strategy-routine-review", "category-system"). composePresetPrompt loads those blocks once at process start (via sync.OnceValues), validates that every referenced ID exists (a typo fails at unit-test time, not at a user's first run), and concatenates them in order into a single base prompt.

After block concatenation:

  1. Option directives — each preset option resolves to a choice (submitted value or the option's Default). Non-empty Directive values are appended under a Markdown heading named for the option's Label.
  2. Additional instructions — the household's per-workflow tuning (AdditionalInstructions, capped at 4,000 characters) is appended under ## Additional instructions. This mirrors Mintlify's "additional prompt over the base prompt" model.

EnableWorkflowFromPreset

func (s *Service) EnableWorkflowFromPreset(
    ctx context.Context,
    slug string,
    params EnableWorkflowFromPresetParams,
) (*AgentDefinitionResponse, error)

Steps:

  1. Lookup the preset by slug — ErrNotFound when absent.
  2. Check for an existing workflows row with source_template = slugErrConflict on a duplicate enable.
  3. Compose the base prompt from PromptBlocks.
  4. Resolve and append option directives.
  5. Append additional instructions if provided.
  6. Call CreateAgentDefinition with SourceTemplate: &slug stamped on the row.

The source_template column is the durable link between a live workflows row and the code-defined preset it was instantiated from. ListWorkflowPresets uses it to annotate the catalog with enablement state (slug, enabled toggle state) for the gallery view.

Preset catalog

The starter catalog (order = gallery display order):

Slug Category Trigger Tool scope Est. cost/run
rule-foundation Setup & Bulk On demand (one-off) read_write $0.50
bulk-catchup Setup & Bulk On demand (one-off) read_write $0.20
series-foundation Setup & Bulk On demand (one-off) read_write $0.50
counterparty-foundation Setup & Bulk On demand (one-off) read_write $0.50
routine-reviewer Categorization & Review After each sync read_write $0.02
transaction-reviewer Categorization & Review After each sync read_write $0.04
series-curator Enrichment After each sync read_write $0.03
counterparty-curator Enrichment After each sync read_write $0.03
weekly-money-digest Insights & Reports Weekly (Mon 07:00) read_only $0.05
backlog-closer Categorization & Review Weekly (Mon 07:00) read_write $0.08
monthly-close Insights & Reports Monthly (1st, 08:00) read_only $0.07
large-charge-sentinel Alerts & Anomalies After each sync read_write $0.03

The Enrichment category holds focused, single-dimension curators that keep the series and counterparty catalogs accurate and enriched as data arrives — narrower and deeper than the general transaction-reviewer. They default to after-each-sync (the doctrine's "improve the enrichment layer on every sync") and are drawer-switchable to a weekly cadence. The two Foundation one-offs (series-foundation, counterparty-foundation) are the recurring-series / counterparty counterparts to rule-foundation: a single deep pass that authors assign_series / assign_counterparty rules from history.

On-demand (one-off) workflows (OneOff: true) have no recurring trigger: the scheduler and post-sync hook both skip them, and they run only when a human clicks Run now. The gallery renders them with copy/run/settings icon buttons instead of a run toggle. The first Run (or an explicit Settings → save) instantiates a manual-only agent_definition (enabled, no cron, no trigger_on_sync_complete); POST /-/workflow-presets/{slug}/run does the instantiate-on-first-use + dispatch in one call (admin-only, consent-gated). rule-foundation, series-foundation, and counterparty-foundation default to Sonnet; bulk-catchup to Haiku.

Preset options

Options are per-preset single-selects rendered in the configure drawer; the chosen choice's directive (if any) is appended to the composed prompt. The default choice carries an empty directive, so the base prompt is unchanged unless the household picks a non-default. The service validates submitted choices against the declared set; unknown values fall back to the option's Default.

Key Used by Choices Default
apply_mode routine-reviewer, backlog-closer, bulk-catchup auto (apply categories), flag_only (review only, no writes) auto
rule_mode rule-foundation create_apply (create rules & backfill history), create_only (create rules, skip retroactive backfill) create_apply
lookback_window large-charge-sentinel 7 / 30 / 90 days 7
report_verbosity large-charge-sentinel concise (headline findings), detailed (full evidence) concise

When flag_only is chosen, a directive is appended to the prompt that explicitly prohibits calling update_transactions to write a category. The lookback_window and report_verbosity directives are defined as the shared lookbackWindowOption / reportVerbosityOption in internal/service/workflow_presets.go.


2. Database Schema

workflows table (formerly agent_definitions)

Column Type Notes
id UUID Primary key (auto-gen)
short_id TEXT 8-char base62 alias (trigger-generated)
slug TEXT URL-safe identifier; unique; matches preset slug
name TEXT Display name
prompt TEXT Fully-composed prompt (base blocks + option directives + additional instructions)
tool_scope TEXT read_only or read_write
model TEXT Model override; empty = server default
max_turns INT Per-run turn cap; default 10
max_budget_usd NUMERIC(10,4) Per-run cost cap; default 1.00
enabled BOOLEAN Run toggle; false = instantiated but paused
schedule_cron TEXT Cron expression; NULL = no cron
trigger_on_sync_complete BOOLEAN Fire after each successful sync
quiet_hours_start TEXT "HH:MM" 24h; NULL disables window
quiet_hours_end TEXT "HH:MM" 24h
source_template TEXT Preset slug this row was instantiated from; NULL = hand-authored
created_at TIMESTAMPTZ
updated_at TIMESTAMPTZ

workflow_runs table (formerly agent_runs)

Column Type Notes
id UUID Primary key
short_id TEXT 8-char base62 alias
workflow_id UUID FK -> workflows.id; SET NULL on delete (history preserved)
trigger TEXT manual | cron | webhook
status TEXT in_progress | success | error | skipped
started_at TIMESTAMPTZ
completed_at TIMESTAMPTZ NULL while in progress
error_message TEXT NULL on success
total_cost_usd NUMERIC(10,4) Actual cost recorded by the sidecar
total_tokens INT
max_turns_used INT Snapshot of the definition's max_turns at run time
hit_cap TEXT max_turns | max_budget | NULL
operator_note TEXT Admin-editable annotation (PATCH endpoint)
prompt_prefix TEXT Per-run prefix prepended to the prompt

The source_template IS NOT NULL predicate on the workflows join is the standard filter distinguishing workflow runs (preset-backed) from hand-authored agent runs in ListAllAgentRuns and WorkflowRunStatusCounts.


3. Governance Gates

3.1 First-enable consent

Enabling any workflow sends Claude over the household's financial ledger at the Anthropic API's cost. Before the first enable, the configure drawer requires an explicit consent checkbox:

  • Server-side check: EnableWorkflowPresetAdminHandler calls svc.WorkflowsConsentAcknowledged(ctx) before processing the request. When the household has not yet acknowledged and the form omits consent=true, the handler returns HTTP 400 CONSENT_REQUIRED.
  • One-time acknowledgement: On the first successful EnableWorkflowFromPreset call, AcknowledgeWorkflowsConsent writes the current UTC timestamp to app_config[workflows.consent_acknowledged_at]. Subsequent enables skip the consent checkbox.
  • Read path: WorkflowsConsentAcknowledged checks for a non-empty value in that key; a read error returns false (safe default: consent required).

3.2 Household spend ceiling

A configurable 30-day rolling spend ceiling (app_config[agent.global_max_budget_usd]) blocks new runs once the window total exceeds the cap:

  • Window: HouseholdCeilingWindow = 30 x 24h rolling from time.Now().
  • Measurement: HouseholdCostSince sums total_cost_usd across all non-skipped runs started within the window.
  • Enforcement: Orchestrator.checkHouseholdCeiling is called at the top of both RunNow (manual) and RunOrSkip (cron/webhook). For cron fires, a ceiling-blocked run still leaves a skipped row so the history shows what was missed.
  • Gallery banner: buildWorkflowSpendBanner derives the gallery's spend state — a warning appears at >= 80% of the ceiling; an error banner ("Workflows paused") appears once spending meets or exceeds the cap.
  • No ceiling (nil or <= 0): No enforcement is applied.

3.3 Post-sync debounce

trigger_on_sync_complete presets fire via Orchestrator.FireSyncCompleteAgents, which is called from the sync engine's OnSyncComplete hook after every successful sync. Because a webhook burst or rapid manual re-sync could fan out multiple runs within minutes, each definition is debounced:

  • Window: PostSyncDebounceWindow = 15 minutes.
  • Check: RecentRunExistsForDefinition queries workflow_runs for a non-skipped run started within the window for that definition.
  • Effect: A debounced trigger is a silent skip — no skipped row, no log spam. The next actual run still picks up all transactions synced since it last ran, so no coverage is lost.

3.4 Admin-only enablement

Instantiating a workflow from a preset is restricted to admin users:

  • The router mounts EnableWorkflowPresetAdminHandler under r.With(RequireAdmin(sm)).
  • Non-admin users can view the gallery but the "Set up" button renders disabled with an explanatory tooltip.
  • Once instantiated, the workflow's run toggle (enable/disable) is accessible to any logged-in editor.

3.5 Per-run safety caps

Each workflows row carries:

  • max_turns — the Claude SDK halts the run cleanly after this many tool-call turns. Default: 10. Hit cap records hit_cap = "max_turns".
  • max_budget_usd — the sidecar aborts mid-run when accumulated cost reaches this value. Default: $1.00. Hit cap records hit_cap = "max_budget".

The household spend ceiling (see §3.2) is a separate, server-side gate evaluated before the run starts. Per-run caps are enforced inside the sidecar.

3.6 Quiet hours

Each workflow can declare a quiet window (quiet_hours_start, quiet_hours_end, both "HH:MM" 24-hour strings). The scheduler skips cron fires that would land inside the window and reschedules to the first minute after the window closes. Post-sync and manual runs are not affected by quiet hours.


4. Admin UI Surfaces

4.1 Gallery — /workflows

Handler: WorkflowsGalleryPageHandler (internal/admin/workflows_gallery_page.go) Template: pages.WorkflowsGallery (internal/templates/components/pages/workflows_gallery.templ) Alpine factory: workflowsGallery (static/js/admin/components/workflows_gallery.js)

The gallery renders the preset catalog grouped by category. Each category becomes a SettingsSection; each preset becomes a SettingsRow. The right-side control is state-dependent:

  • Not yet enabled: A "Set up" button opens the preset's configure drawer (admin only; disabled with tooltip for non-admins).
  • Enabled + active: A toggle switch (enabled/running). Toggling calls /-/workflows/{slug}/enable or /-/workflows/{slug}/disable.
  • Enabled + paused: The toggle shows the paused state; enabling resumes it.

Top-of-page banners:

  • Runtime not ready: Shown when GetAgentSubsystemStatus returns Ready = false (no auth token configured, or sidecar binary not found). Links to Settings -> Agents.
  • Spend ceiling warning / over: Derived from HouseholdSpendStatus. Warning at >= 80%; error banner with "Workflows paused" copy once over.

Configure drawer: A right-side slide-over panel (one per available preset, rendered only for admins). Contents:

  • Preset description and trigger summary.
  • Schedule selector (scheduled presets only): cron preset dropdown (Weekly / Monthly / Daily); post-sync presets skip this field.
  • Per-preset option selects (e.g., "Apply mode" for categorization presets).
  • Additional instructions textarea: the household's tuning appended to the base prompt.
  • Projected cost hint: Computed reactively by projectedCost(cron, estPerRun, postSync) in the Alpine factory. For scheduled presets this is estPerRun x runs/month; for post-sync it shows the per-run cost estimate.
  • Consent checkbox (first-enable only; hidden once ConsentAcknowledged).
  • "Enable" CTA: Submits the drawer form to POST /-/workflow-presets/{slug}/enable. On success, reloads the page so the row shows the run toggle.

4.2 Runs tab — /workflows/runs

Handler: WorkflowRunsPageHandler (internal/admin/workflows_runs_page.go) Template: pages.WorkflowRuns (internal/templates/components/pages/workflows_runs.templ) Alpine factory: workflowsRuns (static/js/admin/components/workflows_runs.js)

The runs tab shows offset-paginated run history scoped to preset-backed workflows (source_template IS NOT NULL). Features:

  • Status tabs: All / Success / Error / In Progress / Skipped — each tab carries a count badge derived from WorkflowRunStatusCounts.
  • Workflow filter dropdown: Narrows to one instantiated workflow. The dropdown lists only enabled presets (those with a workflows row).
  • Run rows: Reuse the shared components.AgentRunRow shape — status badge, trigger chip, cost, duration, per-run report links (chips loaded via ListReportSummariesForRunIDs).
  • Overflow menu: Each row has a "Re-run" action (fires POST /-/workflows/{slug}/run); gated on SubsystemReady.
  • Offset pagination: Prev / Next links step by 50 rows (workflowRunsPageLimit), preserving the active status and workflow filters.

4.3 Run detail — /workflows/runs/{shortId}

Reuses the existing AgentRunDetailPageHandler (shared with the agents surface). Shows the full NDJSON transcript streamed from disk, the operator note PATCH form, and linked reports.

4.4 Tab bar

Both /workflows and /workflows/runs share a two-tab nav bar rendered by workflowsTabBar("gallery"|"runs") — Gallery (layout-grid icon) and Runs (history icon). The tab bar sits immediately under the page header.


5. Route Catalog

Admin page routes (session-cookie auth)

Method Path Guard Handler
GET /workflows Editor WorkflowsGalleryPageHandler
GET /workflows/runs Editor WorkflowRunsPageHandler
GET /workflows/runs/{shortId} Editor AgentRunDetailPageHandler
GET /workflows/runs/{shortId}/live Editor AgentRunLiveHandler

Admin action routes (/-/ prefix, session-cookie auth)

Method Path Guard Action
POST /-/workflow-presets/{slug}/enable Admin Instantiate a workflow from a preset
POST /-/workflows/{slug}/enable Editor Flip enabled = true on an instantiated workflow
POST /-/workflows/{slug}/disable Editor Flip enabled = false
POST /-/workflows/{slug}/run Editor Trigger an immediate manual run (async)
POST /-/workflows/runs/{shortId}/note Editor Set/clear the operator note on a run
POST /-/workflows/settings Admin Update agent subsystem settings
POST /-/workflows/test Admin Smoke-test the agent runtime
POST /-/workflows/notify-test Admin Fire a test notification to the configured webhook
POST /-/workflows/cleanup Admin Run the agent cleanup pass on demand

REST API routes (/api/v1/, API-key auth)

Full details in docs/api-endpoints.md. Summary of the Workflows surface:

Method Path Scope Description
GET /workflows R List all workflow definitions with last_run inlined
GET /workflows/{slug} R One definition; accepts slug, short_id, or UUID
POST /workflows W Create a hand-authored workflow definition
PATCH /workflows/{slug} W Partial update
DELETE /workflows/{slug} W Delete definition; historical runs preserved
POST /workflows/{slug}/enable W Flip enabled = true
POST /workflows/{slug}/disable W Flip enabled = false
POST /workflows/{slug}/run W Trigger an immediate run
GET /workflows/{slug}/runs R Per-definition run history
GET /workflows/runs R Cross-definition run history
GET /workflows/runs/{shortId} R One run detail
PATCH /workflows/runs/{shortId} W Set/clear operator note
GET /workflows/runs/{shortId}/transcript R NDJSON transcript stream
GET /workflows/runs/recent-errors R Errored runs in the last N hours
GET /workflows/settings R Agent subsystem config (tokens masked)
PUT /workflows/settings W Update settings
GET /workflows/status R Cheap readiness probe
POST /workflows/test W Smoke test
POST /workflows/cleanup W Cleanup pass on demand
GET /workflows/prompt-blocks R Parsed prompt-block library
GET /workflow-presets R Preset catalog with enabled-state annotations
POST /workflow-presets/{slug}/enable W Instantiate a workflow from a preset

6. Runtime — Orchestrator and Scheduler

6.1 Orchestrator

Orchestrator (internal/service/agent_orchestrator.go) drives one workflow run end-to-end. It owns:

  1. Household ceiling check — before acquiring the semaphore.
  2. Concurrency semaphoreagent.Semaphore (configurable via agent.max_concurrent, default 1).
  3. Run row creationCreateAgentRunDB writes the workflow_runs row with status = in_progress.
  4. API key mintMintRunAPIKey creates a scoped api_keys row with actor_type = 'agent' (see §7).
  5. JobSpec assemblyAssembleJobSpec reads auth tokens from app_config (AES-256-GCM encrypted), resolves the sidecar binary path, and builds the agent.JobSpec the runner needs.
  6. Sidecar invocationrunner.Run(ctx, spec) execs breadbox-agent (the TypeScript Claude Agent SDK sidecar), passes it the MCP config, Anthropic credential, and full prompt. The sidecar connects to Breadbox MCP via stdio using the minted run key.
  7. Row completion — on sidecar exit, marks the run success or error with actual cost, token count, and cap signal.
  8. API key revocation — always, in a defer with a fresh context.Background() timeout so cancellation of the parent context cannot prevent revocation.

Run entry points

Method When used Leaves skipped row?
RunNow / RunNowWith Manual "Run now" from the admin UI or REST API (synchronous) No — returns ErrConcurrencyLocked to caller
RunNowAsyncWith "Run now" button (returns immediately; sidecar runs in goroutine) No
RunOrSkip Cron and webhook triggers Yes — status = skipped when semaphore full or ceiling reached
FireSyncCompleteAgents Post-sync event (called from OnSyncComplete hook) No — debounced fires are silent no-ops

6.2 Scheduler

AgentScheduler (internal/service/agent_scheduler.go) wraps robfig/cron and registers one cron entry per enabled, scheduled workflow definition. On Reload, it tears down all existing entries and re-registers from the database. This is triggered by NotifyDefinitionChanged after any CRUD mutation on a workflow definition.

Cron fires call RunOrSkip and respect quiet hours — a fire scheduled inside the quiet window is skipped with a log entry; the scheduler reschedules to the first minute after the window ends.

6.3 Sync hook

Orchestrator.FireSyncCompleteAgents is wired to sync.Engine.OnSyncComplete in serve.go. The sync engine has no knowledge of the agents/workflows package (no import cycle). When called:

  1. Queries ListAgentDefinitionsForSyncWebhook for enabled definitions with trigger_on_sync_complete = true.
  2. Applies the debounce check (see §3.3).
  3. Dispatches each definition in its own goroutine, calling RunOrSkip with trigger = "webhook".

6.4 Panic recovery

RunNowAsyncWith wraps the goroutine body in a recover() block. A panic from any downstream code (sidecar NDJSON parser, DB driver, slog handler) is caught, logged with the full stack trace, and the run row is marked errored. The concurrency slot is always released.


7. Actor Identity and API Key Lifecycle

Mint-and-revoke

Every workflow run creates a scoped api_keys row at start and revokes it at completion. The key is never exposed to callers.

// MintRunAPIKey — called by Orchestrator before sidecar exec
s.CreateAPIKey(ctx, CreateAPIKeyParams{
    Name:              fmt.Sprintf("agent:%s:%s", def.Slug, runShortID),
    Scope:             scope, // "read_only" or "full_access"
    ActorType:         "agent",
    ActorName:         def.Name,
    AgentDefinitionID: def.ID,
})
  • ActorType = "agent" distinguishes workflow writes in the activity timeline from human edits.
  • ActorName = def.Name (the workflow's display name, e.g., "Routine Reviewer") is stamped on every write the run makes.
  • AgentDefinitionID is the durable link: ResolveAgentSlugForActor / GetAgentIdentityByApiKeyID resolve any agent actor to its definition for name and avatar rendering.
  • The key name (agent:<slug>:<runShortID>) is the audit trail in api_keys; the per-run row in workflow_runs does not store the plaintext key.

Revocation always uses a fresh context.WithTimeout(context.Background(), 10*time.Second) so a cancelled parent (user closed the run-now request, server shutdown) does not prevent the revoke.

Attribution

The sidecar receives the minted run key as BREADBOX_API_KEY. runMCPStdio (internal/cli/mcp.go) binds it as the actor floor via ValidateAPIKey. MCPServer.rebindActorFromClientInfo is gated on AgentRunShortIDFromContext(ctx) == "" — it upgrades anonymous clients but never clobbers a real run key, so workflow runs are always attributed to their specific definition regardless of the Claude SDK's generic clientInfo.

Tool scope

  • read_only definitions mint a read_only-scoped key, restricting the sidecar to read MCP tools only.
  • read_write definitions mint a full_access-scoped key, allowing the sidecar to call update tools (e.g., update_transactions, create_transaction_rule).

8. Notification Sink

Workflows fire outbound notifications when noteworthy events occur (typically when a run submits a report via submit_report). Delivery fans out to a list of channels — each its own sink in its own format.

Channels

The sink is managed on its own Settings → Notifications page (/settings/notifications, admin-only). Each channel renders as one quiet row (name · masked URL · format + a single status badge) with a gear button that opens its edit Drawer (components.Drawer); the Add channel button opens the same drawer empty. Channels are stored as a JSON array under app_config[notify.channels] (plaintext); the deep-link origin shared across all channels is derived (see below).

Each channel carries:

  • id — 8-char short id.
  • name — operator label.
  • url — the http(s) sink.
  • formatauto (default) | ntfy | slack | discord | googlechat | json.
  • min_priorityinfo (default) | warning | critical; this channel only receives reports at or above the floor.
  • ntfy_token — optional Bearer token for a protected ntfy topic (ntfy only).
  • enabled — disabled channels are skipped.
  • last_status{ok, at, format, detail}, the most recent delivery attempt (surfaced inline on the page).

A workflow notification fans out to every enabled channel, each delivered + retried independently; SendWorkflowNotification returns the first delivery error after attempting all of them. CRUD is handled by AddNotificationChannel / UpdateNotificationChannel / SetNotificationChannelEnabled / DeleteNotificationChannel; the edit drawer never echoes secrets, so UpdateNotificationChannel treats a blank URL/token as "keep current". SendTestToChannel backs the per-channel Test button, and POST /-/notifications/test tests all enabled channels.

Back-compat. When notify.channels is empty but the legacy notify.webhook_url (+ notify.format / notify.min_priority) keys are set, a single "Default" channel is synthesized so pre-multi-channel configs keep delivering. The synth channel is never silently persisted by a send (which would freeze the legacy keys) — it's migrated into the array only when the operator first mutates a channel.

Wire formats

The per-channel format (or what auto sniffs it to, from the URL host) selects the shape. All requests use a 10-second timeout (notifyHTTPClient) and User-Agent: breadbox-workflows.

  • ntfy (docs) — body is the markdown message; metadata rides in headers: X-Title, X-Priority (info→3 / warning→4 / critical→5), X-Markdown: yes, X-Tags (priority emoji), X-Click + X-Actions (a "View report" button, when an absolute deep link is available), and Authorization: Bearer when a channel token is set. Non-ASCII headers are RFC 2047-encoded.
  • slack — Slack incoming-webhook JSON {"text": …} in mrkdwn (**bold***bold*, [label](url)<url|label>), with a priority emoji + "View report →" link. Auto-detected for hooks.slack.com.
  • discord — Discord webhook JSON {"content": …} (markdown, 2000-char cap) + a bare deep link. Auto-detected for discord.com / discordapp.com webhooks.
  • googlechat — Google Chat incoming-webhook JSON {"text": …} (Slack-style markup but unicode emoji, since Google Chat doesn't render :shortcode: names). Auto-detected for chat.googleapis.com.
  • json — the generic NotificationPayload envelope as application/json (relays, bridges, custom consumers).
  • auto (default) — resolves to the matching provider above when the URL is recognizable, else json.

Reliability. Delivery is retried up to 3 times with exponential backoff (200ms → 400ms) on transient failures (network error, HTTP 429, 5xx); a non-429 4xx fails fast. The deep link is absolutized with the resolved deep-link origin so ntfy tap-through and relative body links resolve to the real report.

Deep-link origin (derived). Notifications fire from background jobs, so there's no HTTP request to read the public origin from at send time. Instead, every visit to the Notifications page captures the admin's own request origin (X-Forwarded-Proto / X-Forwarded-HostHost, same derivation as the MCP endpoint / OAuth issuer / setup links) into notify.detected_base_url. Service.ResolveNotifyBaseURL returns the manual override notify.public_base_url when set, otherwise the detected origin — so deep links "just work" without the operator typing a URL. The override is only needed for split-horizon setups (Breadbox reached at a different public URL than the admin browses from, e.g. behind a tunnel).

NotificationPayload (the json envelope)

type NotificationPayload struct {
    Event    string `json:"event"`              // "test" | "report"
    Title    string `json:"title"`
    Body     string `json:"body,omitempty"`
    Priority string `json:"priority,omitempty"` // info | warning | critical
    Workflow string `json:"workflow,omitempty"` // originating workflow name
    URL      string `json:"url,omitempty"`      // deep link back into Breadbox
    SentAt   string `json:"sent_at"`            // RFC3339
}

9. REST API Surface

Authentication

All /api/v1/workflows/* and /api/v1/workflow-presets/* endpoints share the same API key middleware as the rest of the REST API (X-API-Key header). Scope requirements follow the table in §5 (read-only keys cannot call write endpoints).

Workflows list — GET /api/v1/workflows

Returns a bare JSON array of AgentDefinitionResponse objects, each with last_run inlined. The source_template field identifies preset-backed workflows; null means hand-authored.

Preset gallery — GET /api/v1/workflow-presets

Returns an annotated catalog: each preset carries enabled, workflow_slug (when instantiated), and workflow_enabled (the instantiated workflow's run toggle). The gallery UI reads this endpoint client-side for reactive enable-state.

Enable preset — POST /api/v1/workflow-presets/{slug}/enable

Enforces the same consent gate as the admin action route. Accepts the same form fields:

Field Default Notes
consent Required on first enable when consent not yet recorded
enabled "true" Pass "false" to instantiate paused
schedule_cron preset default Scheduled presets only
additional_instructions "" Capped at 4,000 characters
<option.Key> option.Default One field per preset option (e.g., apply_mode)

Returns 409 CONFLICT when already enabled; 404 NOT_FOUND for an unknown slug; 400 for invalid parameters; 200 with the new AgentDefinitionResponse on success.

Run history — GET /api/v1/workflows/runs

Cross-definition run history. Each row includes agent_slug and agent_name from the joined workflows row for deep-link and attribution display. Supports these query parameters: status, trigger, hit_cap (max_turns/max_budget/any), start, end, agent (slug to narrow to one definition), limit (max 200), offset.

Settings

GET /api/v1/workflows/settings returns AgentSettingsResponse. Token fields (subscription_token, anthropic_api_key) are returned as masked display strings (e.g., "sk-ant-oat01-XXXXXXXXX****wxyz"); plaintext never leaves the server.

PUT /api/v1/workflows/settings accepts partial updates. Nil fields are unchanged; an empty string for token fields clears the stored encrypted value.


10. Configuration Keys

All keys are stored in the app_config table and read at runtime via appconfig.* helpers.

Key Type Default Description
agent.auth_mode string "subscription" "subscription" (Claude OAuth token) or "api_key" (Anthropic API key)
agent.subscription_token string (encrypted) Claude OAuth token (sk-ant-oat01-...)
agent.anthropic_api_key string (encrypted) Anthropic API key (sk-ant-...)
agent.max_concurrent int 1 Server-wide cap on simultaneous workflow runs
agent.global_max_budget_usd float 30-day rolling household spend ceiling; empty = no cap
agent.runtime_path string Absolute path to breadbox-agent binary; empty = auto-discover
agent.transcript_dir string Directory for per-run NDJSON transcripts; falls back to agent.DefaultTranscriptDir()
agent.run_retention_days int 30 Days to keep completed workflow_runs rows; 0 = disabled
workflows.consent_acknowledged_at RFC3339 Non-empty = household has given first-enable consent
notify.channels JSON array Notification channels (multi-sink): per-channel url/format/min_priority/ntfy_token/enabled/last_status
notify.public_base_url string Optional manual override for the deep-link origin; empty = use the auto-detected origin
notify.detected_base_url string Deep-link origin auto-captured from the admin's request on each Notifications-page load; used when no override is set
notify.webhook_url string Legacy single webhook; synthesized into a "Default" channel when notify.channels is empty
notify.format string "auto" Legacy single-webhook format (back-compat seed for the synthesized channel)
notify.min_priority string "info" Legacy single-webhook priority floor (back-compat seed)

Token values (agent.subscription_token, agent.anthropic_api_key) are AES-256-GCM encrypted at rest via appconfig.ReadEncrypted / appconfig.WriteEncrypted, using the server's ENCRYPTION_KEY.


Preset vs. custom workflows

/workflows is the single management surface for all automations. Two kinds coexist in the gallery, backed by the same data layer:

  • Preset workflows — instantiated from a code-defined template (source_template IS NOT NULL). The setup/reconfigure drawer exposes the preset's options; the prompt is composed from the template.
  • Custom workflows — hand-authored (source_template = NULL). Created and edited via the custom-workflow drawer, where the operator authors the entire prompt themselves alongside schedule, model, and tool scope.

Shared mechanics:

  • Both use the workflows table (formerly agent_definitions) and workflow_runs table (formerly agent_runs).
  • The orchestrator, scheduler, and sidecar are shared. There is one concurrency semaphore for all runs regardless of origin.
  • The Settings → Workflows page (/settings/workflows) configures the shared subsystem (Anthropic credentials, concurrency cap, global budget ceiling, notification webhook, runtime path).

The legacy /agents admin surface — the standalone agent-definition list, the hand-authored form (/agents/new, /agents/{slug}/edit), the per-agent detail page, and the prompt-library wizard (/agent-prompts) — was removed. Those URLs now redirect to /workflows.