Workflows
A workflow is a procedure written as a Ruby class in app/workflows/. A trigger can run one
instead of rendering its prompt template. It sits between the two primitives Zimmer already had:
Trigger → Workflow → Session
| Primitive | What it is | Lives in | Role |
|---|---|---|---|
| Trigger | data | Postgres | Binds an event to what should run. Editable without a deploy. |
| Workflow | code | this repo | Validates the event’s payload, decides the trusted identifiers, writes the seed prompt. Never stored in the database. |
| Session | resource | the agent runtime | The agent run itself. |
A template trigger gsubs its event into a string and starts a session with it. Nothing checks the
payload, and every identifier the poller had in hand — a channel id, a thread timestamp — reaches
the agent only as prose, if at all. A workflow gives that path three things it lacks: a place to
reject a malformed payload before a token is spent, a place to put identifiers the model can read
but cannot rewrite, and a procedure two triggers can share and a PR can review. The full argument
is on #18.
Anatomy of a workflow
Section titled “Anatomy of a workflow”class EchoWorkflow < ApplicationWorkflow workflow_id "echo" title "Echo" description "Restate a message back, verbatim. The reference workflow: it resolves nothing and equips nothing."
param :message, :text, required: true, label: "Message", widget: :textarea, help: "The text the agent restates.", example: "Hello from a workflow."
def plan(input) Workflow::Plan.new( resolved: {}, instructions: <<~MD Restate the message below back, verbatim, and do nothing else. ... <message> #{input.message} </message> MD ) endendworkflow_idis what a trigger stores intriggers.workflow_id. It is opaque and permanent: dotted lower-snake segments (echo,slack.triage_mention), declared once, and independent of the class name, so a workflow can be renamed without orphaning its triggers.titleanddescriptionare for people. The label istitlerather thannamebecausenameisModule#name, and overriding it on a class breaks every error message and autoloader lookup that asks the class what it is called.paramdeclares one input. It is a descriptor first —label,help,widget,exampleandoptionsare what a param form will be rendered from — and the validation is derived from it.requiresnames what a run needs from the catalog:agent_root:,mcp_servers:,skills:andgoal:. Names, not credentials.echodeclares none.plan(input)turns validated input into aWorkflow::Plan.
The input boundary
Section titled “The input boundary”ApplicationWorkflow.build_input!(payload) validates the payload against the workflow’s params and
raises Workflow::Input::InvalidInputError, carrying every problem at once, before a session exists.
It is strict in three ways ActiveModel on its own is not:
-
An unknown key is an error. A payload with a field the workflow does not declare is a payload it does not understand, so it is refused rather than dropped.
-
A value must already be its type. ActiveModel would cast
"abc"to0for an integer and totruefor a boolean. The boundary accepts only the type itself or its unambiguous string spelling:Type Accepts Becomes stringa String String texta String (a textarea, when forms exist) String integeran Integer, or a string of digits Integer booleantrue,false,"true","false"true / false datea Date, or "YYYY-MM-DD"naming a real dateDate -
requiredmeans present. A missing or whitespace-only value fails;falsesatisfies a required boolean.
A param with options: accepts only those values.
resolved and instructions
Section titled “resolved and instructions”#plan returns two outputs, kept apart because they are trusted differently:
resolvedholds the identifiers the run may act on: a reply channel id, the acting principal, a target repo. It is recorded on the run’sworkflow_runsrow. The agent may be shown it; nothing the agent says can change it. It has to be JSON already — a Symbol or a Time is refused, because it would come back out of the column as something else.instructionsis the seed prompt. The model is free to interpret it, which is why nothing that has to be trustworthy goes in it.
#plan may look things up, but it may not cause side effects, and it must raise rather than guess
when a lookup fails. It runs before the session exists, so a raise leaves nothing behind.
The run record
Section titled “The run record”workflow_runs has one row per session a workflow started: session_id (unique), trigger_id,
workflow_id, the validated input and resolved, both jsonb. It is deliberately not
sessions.metadata, which dozens of code paths write.
A run is written once and is read-only afterwards — WorkflowRun#readonly? refuses every write
through a record: save, update!, update_columns. (A class-level update_all bypasses it, as it
bypasses every model rule.) It is written the moment the session row commits and before the
session’s start job is enqueued, so by the time anything spawns the agent, resolved is on record.
The foreign keys never block cleanup elsewhere: deleting a session deletes its run, and deleting a
trigger keeps the run and clears its trigger_id.
Runs are listed at /supervisor/workflow_runs, index and show only — the panel has no form and no
Delete button, because a run refuses both.
Running one from a trigger
Section titled “Running one from a trigger”WorkflowRunner.call(trigger:, payload:) is the whole path:
The spawn goes through the trigger’s own create_session!, so a workflow fire gets the
burst cap, pending-session
dedup, genesis and fire counters a
template fire gets. What differs is the prompt, which is the plan’s instructions, and the
equipment, which is the workflow’s:
- MCP servers and skills are a union of the agent root’s defaults and what the workflow declares.
- Hooks and plugins are the root’s defaults. A workflow cannot declare them.
- The goal is the workflow’s declared goal id, resolved to its text at spawn the same way a trigger’s goal is. With none declared, the session has no goal.
Everything that goes wrong before the spawn fails closed — no session, no run, and the trigger’s
counters do not move: an unregistered id, a payload that fails validation, a #plan that raises, and
a #plan that returns anything but a Workflow::Plan. A burst-suppressed fire creates nothing. A
fire that tips the burst cap spawns the usual burst-notice session, which is not a run of the
workflow and gets no workflow_runs row.
What a workflow trigger gives up
Section titled “What a workflow trigger gives up”A trigger has exactly one of prompt_template and workflow_id. The model validates it, and
the triggers_prompt_template_xor_workflow_id check constraint holds the database to it even for a
write that skips validations. A blank prompt_template on a workflow trigger is stored as NULL,
because every edit form submits the field. Beyond its template, a workflow trigger:
- may not set
reuse_session.resolvedis bound to a session when it spawns, and letting a later fire re-bind a conversation already running is a privilege-escalation shape nobody has designed yet. The model validates it, thetriggers_workflow_trigger_never_reuses_sessioncheck constraint holds the database to it, andcreate_session!refuses a workflow trigger’s reuse fire regardless. The per-session wakes are all reuse triggers, so they stay template triggers. - carries no equipment of its own. Its
mcp_servers,catalog_skills,catalog_hooksandcatalog_pluginsmust be empty and itsgoalblank. The workflow declares them. - is pinned to the workflow’s agent root, when the workflow declares one. A root-agnostic
workflow like
echodeclares none, and then the trigger’sagent_root_namedecides. - is never healed onto a successor root. A template trigger whose root left the catalog is repointed at a matching successor on its next fire. A workflow trigger is not: repointing it would start the run in a root nobody reviewed the workflow for, so an agent root the catalog does not carry raises at spawn instead.
- names a registered workflow.
The template firing sites still call Trigger#interpolate_prompt and create_session!(prompt:)
directly. A workflow trigger reaching either raises instead of starting a session from a prompt it
does not have.
The registry
Section titled “The registry”WorkflowRegistry::WORKFLOWS lists every workflow by hand, and WorkflowRegistry.find! raises
UnknownWorkflowError — a KeyError — for any id not on it. The list is deliberately not built by
self-registration: under Zeitwerk, a Class.inherited hook runs only once a class has loaded, which
is every class in production (eager loading) and only the referenced ones in test and development.
A registry filled that way would be complete in one environment and empty in another.
test/workflows/workflow_registry_test.rb fails if a class under app/workflows/ is missing from
the list or two workflows claim one id.
Catalog references are checked in CI, not healed at runtime
Section titled “Catalog references are checked in CI, not healed at runtime”A trigger’s catalog references are data, so they can go stale between deploys, and a fire
heals around a name the catalog stopped resolving.
A workflow’s references are code. test/workflows/workflow_catalog_references_test.rb resolves every
registered workflow’s declared agent root, MCP servers, skills and goal against the real catalog, so
the PR that removes or renames a catalog entry a workflow needs fails CI and names the workflow.
If the catalog drifts at runtime anyway, the spawn still fails closed: a session will not validate with an MCP server or skill the catalog cannot resolve, and an unknown agent root raises.
Decisions made so far
Section titled “Decisions made so far”Phase 0 had to answer some of #18’s open questions to exist. Each answer is the conservative one, and each is open to revisiting:
planmay do I/O, for resolution only. No side effects, and a failed lookup raises. There is no retry or dead-letter yet: the exception reaches whoever calledWorkflowRunner.- Equipment is a union with the root’s defaults, not an override. A workflow cannot yet ask for a narrower tool surface than its root’s.
- Workflow ids are not versioned. Nothing re-runs a stored input yet, so a param change cannot yet break one.
- No reuse. See above.
What comes next
Section titled “What comes next”- Phase 1 —
slack.triage_mention, the first real workflow, with areply_in_threadtool bound to theresolvedreply channel so the model has no argument through which to post anywhere else. Alongside it,LegacyTemplateWorkflowand aTrigger#fire!refactor, so every firing site goes throughWorkflowRunnerand template triggers become one quarantined workflow. - Phase 2 — a
/workflowsindex and show page, a template/workflow toggle on the trigger form, andPOST /workflows/:id/runsfor parameterized manual runs. - Phase 3 — reuse semantics for workflow triggers, and retiring the template path if no template triggers remain.