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.
- Preset Registry Model
- Database Schema
- Governance Gates
- Admin UI Surfaces
- Route Catalog
- Runtime — Orchestrator and Scheduler
- Actor Identity and API Key Lifecycle
- Notification Sink
- REST API Surface
- Configuration Keys
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 (
ErrConflicton a second enable attempt).
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
}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:
- Option directives — each preset option resolves to a choice (submitted value or the option's
Default). Non-emptyDirectivevalues are appended under a Markdown heading named for the option'sLabel. - 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.
func (s *Service) EnableWorkflowFromPreset(
ctx context.Context,
slug string,
params EnableWorkflowFromPresetParams,
) (*AgentDefinitionResponse, error)Steps:
- Lookup the preset by slug —
ErrNotFoundwhen absent. - Check for an existing
workflowsrow withsource_template = slug—ErrConflicton a duplicate enable. - Compose the base prompt from
PromptBlocks. - Resolve and append option directives.
- Append additional instructions if provided.
- Call
CreateAgentDefinitionwithSourceTemplate: &slugstamped 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.
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.
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.
| 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 |
| 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.
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:
EnableWorkflowPresetAdminHandlercallssvc.WorkflowsConsentAcknowledged(ctx)before processing the request. When the household has not yet acknowledged and the form omitsconsent=true, the handler returns HTTP 400CONSENT_REQUIRED. - One-time acknowledgement: On the first successful
EnableWorkflowFromPresetcall,AcknowledgeWorkflowsConsentwrites the current UTC timestamp toapp_config[workflows.consent_acknowledged_at]. Subsequent enables skip the consent checkbox. - Read path:
WorkflowsConsentAcknowledgedchecks for a non-empty value in that key; a read error returnsfalse(safe default: consent required).
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 24hrolling fromtime.Now(). - Measurement:
HouseholdCostSincesumstotal_cost_usdacross all non-skipped runs started within the window. - Enforcement:
Orchestrator.checkHouseholdCeilingis called at the top of bothRunNow(manual) andRunOrSkip(cron/webhook). For cron fires, a ceiling-blocked run still leaves askippedrow so the history shows what was missed. - Gallery banner:
buildWorkflowSpendBannerderives 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.
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:
RecentRunExistsForDefinitionqueriesworkflow_runsfor a non-skipped run started within the window for that definition. - Effect: A debounced trigger is a silent skip — no
skippedrow, no log spam. The next actual run still picks up all transactions synced since it last ran, so no coverage is lost.
Instantiating a workflow from a preset is restricted to admin users:
- The router mounts
EnableWorkflowPresetAdminHandlerunderr.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.
Each workflows row carries:
max_turns— the Claude SDK halts the run cleanly after this many tool-call turns. Default: 10. Hit cap recordshit_cap = "max_turns".max_budget_usd— the sidecar aborts mid-run when accumulated cost reaches this value. Default: $1.00. Hit cap recordshit_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.
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.
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}/enableor/-/workflows/{slug}/disable. - Enabled + paused: The toggle shows the paused state; enabling resumes it.
Top-of-page banners:
- Runtime not ready: Shown when
GetAgentSubsystemStatusreturnsReady = 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 isestPerRun 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.
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
workflowsrow). - Run rows: Reuse the shared
components.AgentRunRowshape — status badge, trigger chip, cost, duration, per-run report links (chips loaded viaListReportSummariesForRunIDs). - Overflow menu: Each row has a "Re-run" action (fires
POST /-/workflows/{slug}/run); gated onSubsystemReady. - Offset pagination: Prev / Next links step by 50 rows (
workflowRunsPageLimit), preserving the active status and workflow filters.
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.
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.
| 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 |
| 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 |
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 |
Orchestrator (internal/service/agent_orchestrator.go) drives one workflow run end-to-end. It owns:
- Household ceiling check — before acquiring the semaphore.
- Concurrency semaphore —
agent.Semaphore(configurable viaagent.max_concurrent, default 1). - Run row creation —
CreateAgentRunDBwrites theworkflow_runsrow withstatus = in_progress. - API key mint —
MintRunAPIKeycreates a scopedapi_keysrow withactor_type = 'agent'(see §7). - JobSpec assembly —
AssembleJobSpecreads auth tokens fromapp_config(AES-256-GCM encrypted), resolves the sidecar binary path, and builds theagent.JobSpecthe runner needs. - Sidecar invocation —
runner.Run(ctx, spec)execsbreadbox-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. - Row completion — on sidecar exit, marks the run
successorerrorwith actual cost, token count, and cap signal. - API key revocation — always, in a
deferwith a freshcontext.Background()timeout so cancellation of the parent context cannot prevent revocation.
| 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 |
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.
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:
- Queries
ListAgentDefinitionsForSyncWebhookfor enabled definitions withtrigger_on_sync_complete = true. - Applies the debounce check (see §3.3).
- Dispatches each definition in its own goroutine, calling
RunOrSkipwithtrigger = "webhook".
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.
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.AgentDefinitionIDis the durable link:ResolveAgentSlugForActor/GetAgentIdentityByApiKeyIDresolve any agent actor to its definition for name and avatar rendering.- The key name (
agent:<slug>:<runShortID>) is the audit trail inapi_keys; the per-run row inworkflow_runsdoes 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.
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.
read_onlydefinitions mint aread_only-scoped key, restricting the sidecar to read MCP tools only.read_writedefinitions mint afull_access-scoped key, allowing the sidecar to call update tools (e.g.,update_transactions,create_transaction_rule).
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.
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.format—auto(default) |ntfy|slack|discord|googlechat|json.min_priority—info(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.
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), andAuthorization: Bearerwhen 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 forhooks.slack.com.discord— Discord webhook JSON{"content": …}(markdown, 2000-char cap) + a bare deep link. Auto-detected fordiscord.com/discordapp.comwebhooks.googlechat— Google Chat incoming-webhook JSON{"text": …}(Slack-style markup but unicode emoji, since Google Chat doesn't render:shortcode:names). Auto-detected forchat.googleapis.com.json— the genericNotificationPayloadenvelope asapplication/json(relays, bridges, custom consumers).auto(default) — resolves to the matching provider above when the URL is recognizable, elsejson.
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-Host → Host, 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).
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
}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).
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.
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.
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.
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.
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.
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.
/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
workflowstable (formerlyagent_definitions) andworkflow_runstable (formerlyagent_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
/agentsadmin 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.