043 · Workflows
Steps joined into a graph, each step’s output the next one’s input, run as one turn and built on a canvas (spec_workflows_043)
| Status | Done in v0.7.1 (the owner’s request, 2026-10-17): WF-01–WF-21 (people and time, workers running workflows, task alerts, run receipts, schedules and each workflow’s permission mode on 2026-10-18). |
| Source | pkg/engine/flows/flows.go, pkg/engine/flows/expr.go, pkg/engine/flowrun.go, pkg/engine/flowgraph.go, pkg/engine/flowcalls.go, pkg/engine/flowreceipt.go, pkg/engine/tools/flowcalls.go, pkg/engine/turn.go (Run), pkg/engine/commands.go (ListCommands), pkg/api/turn.go (FlowStep), pkg/api/permissions.go (Looser), apps/service/internal/server/workflows.go, apps/service/internal/server/workflowruns.go, apps/service/internal/server/convert.go, pkg/client/convert.go, apps/cli/internal/tui/repl.go, apps/desktop/web/src/studio/WorkflowsPane.tsx, apps/desktop/web/src/studio/RunPanel.tsx, apps/desktop/web/src/studio/WorkflowSchedule.tsx, apps/desktop/web/src/studio/workflowForm.ts, apps/desktop/web/src/workflowRuns.ts, apps/desktop/web/src/FlowRunAlerts.tsx, apps/desktop/web/src/turns.ts |
| Tests | pkg/engine/flows/flows_test.go, pkg/engine/flows/expr_test.go, pkg/engine/flowrun_test.go, pkg/engine/flowcalls_test.go, pkg/engine/tools/flowcalls_test.go, pkg/engine/flowreceipt_test.go, pkg/engine/flowmode_test.go, apps/service/internal/server/workflows_test.go, apps/service/internal/server/workflowruns_test.go, apps/desktop/web/src/studio/workflowForm.test.ts, apps/desktop/web/src/studio/workflowRunUi.test.ts, apps/desktop/web/src/workflowRuns.test.ts |
| Depends on | spec_skills_013 (SK-80, the command folders; skill scripts), spec_gates_041 (rules, guardrails), spec_studio_040 |
Some work is the same few steps every time: read the notes, look something up, draft, tighten, save. A workflow writes those steps down once, joined so each step’s output is the next one’s input, and runs them in order: from the chat as /name, or from Studio, where it’s drawn and built on a canvas.
- WF-01 A workflow is
.agents/workflows/<name>.flow.yaml, beside the.mdcommands there (SK-80):description,nodes(each step:id,type,title,positionon the canvas, and its type’s settings) andedges(from→to: the first step’s output goes to the second). Step types: agent (an agent works on its input asinstructionssay), refine (an agent improves its input), summarize (an agent sums it up;instructionsare the focus), extract (WF-13), mcp_call, a2a_call and api_call (WF-14), workflow (WF-15), human_task and timer (WF-16), the exclusive, inclusive and parallel gateways (WF-12), browser (an agent reaches theinstructions’ goal with its browser tool), accumulate (joins its inputs, each under its step’s title), script (a skill’s script:skill,script,args, the input as its last argument; its output is the step’s), read_file (a workspace file’s text, 256 KiB at most) and save_file (writes its input to a workspace file, asked for as any write is, and passes it on). Agent steps takeagent(none: the active one),skills(given with the prompt, as\skill-namedoes) andrules. email and data are shown, not run yet (flows.Parse). - WF-02 What doesn’t hold is the workflow’s problem, and it isn’t run: a bad name, no steps, a step without an ID or with another’s, an unknown type, a coming-soon one, an agent, refine or browser step without instructions, a script step without its skill and script, a file step without a path, a link to a step that isn’t there or to itself, a loop, a condition that’s both CEL and plain English or that doesn’t compile, a default or error link with a condition, more than one default or error link from a step, a condition on a parallel gateway’s link, a gateway with fewer than two links out, an extract step without fields or with a bad one, a bad retry, timeout or template (
flows.Check). A workflow is saved with steps not filled in yet (a draft); a bad name, ID, type, link or loop isn’t saved. - WF-03 Steps run in order (
flows.Order): each after the steps that feed it, otherwise as listed. A step’s input is what the steps that feed it produced, each under its title when there are several (or for accumulate); a step fed by none gets the run’s input (what follows/name). - WF-04 A workflow runs as a turn: a command turn whose name is a workflow’s (
/name input, over a.mdcommand of that name) runs it (Workspace.runFlow). Each agent step is a turn of its own in the session (its text/name › step title, its prompt the step’s instructions, its input and its skills), so it streams, asks for approval, keeps its guardrails and is judged by the after-agent gates that apply to its agent, as any turn is. The run stops at the first step that fails, with the step and why; its output is what the last steps (those feeding none) produced. - WF-05 Rules on a step: the rules a step names, beyond the gates its turn already applied, judge its output (
gates.Judge, as an after-agent gate): a failing block rule stops the workflow at that step, revise asks the step’s agent once for a revised answer (and says so if it still fails), warn (or an instruction rule) says so in the chat. A rule the workspace hasn’t is said in the chat. Evals never run inside a workflow: they check an agent (or a workflow) when it’s made or changed (spec_evals_042). - WF-06 Each step’s start and end is a turn event (
api.FlowStep,TurnEvent.flow_step): the workflow, the step’s ID, title and type, its place (step 2 of 5), its status (running, done, failed), the start of its output (4 KiB) and its error. The CLI and the chat show a line as a step starts (▸ /brief › Draft (2 of 5)) and an error if it fails. - WF-07 Workflows are listed among the commands (
/name, sourceworkflow) for completion and/help. - WF-08
WorkspaceService.ListWorkflows,SaveWorkflow(from the canvas; problems with nothing written;WORKFLOW_NOT_FOUNDfor an unknown previous name) andDeleteWorkflow(WORKFLOW_NOT_FOUND). - WF-09 Studio › Workflows (
WorkflowsPane): the workflows as/name, each with its step count (or its problem) and ▶; + starts one from a template (Blank, Research brief: notes and a web look-up gathered, drafted and saved; Draft and refine; Script report: a script’s output summed up and saved). A workflow opens on a canvas (@xyflow/react) that takes the room: Studio’s kinds shrink to their icons, and the canvas fills the pane. The step types are in a Steps toolbox floating on the canvas, folded to one button until opened (remembered), grouped (agents, data, calls, people and time, flow; email and data marked soon), each with what it does: a step is dragged onto the canvas and lands where it’s dropped, or clicked to land in the middle of the canvas as shown, clear of the steps there; either way it’s selected. Opening the toolbox moves steps that would be under it into view. The canvas fits the whole workflow (zoomed out to 55% at most). The settings panel beside it is resized by dragging its edge (the width remembered, double-click for the default) and folded to a rail to give the canvas the room (selecting a step unfolds it); skills and rules are chosen in a drop-down list of check boxes (MultiSelect, filtered when long); people and timer steps don’t offer Repeat or retries (the service refuses them too), steps as cards and gateways as diamonds (their icon, title, agent, file or script, or what they still need), joined by dragging from a step’s right edge to another’s left (a link that would loop isn’t made), moved by dragging, removed with Delete. Links show their label, condition, otherwise or on error (dashed); selecting one shows how it’s taken (always, an expression, plain English, otherwise, on error), its condition and its label. Beside the canvas: the selected step’s settings (title, agent, instructions, skills, rules; a script’s skill and script from the skills that have them, and its arguments; a file’s path; an extract step’s fields; an API call’s method, URL, query, headers, body and auth, an MCP call’s server, tool and arguments, an A2A call’s agent, message and headers, values completing{secrets:…}; a call step’s workflow; a step’s for-each list and concurrency (a repeated step’s card is marked ×N); tries, the first wait and the timeout), or the selected link’s, or the workflow’s name and description. Back, Cancel, Save and Delete, unsaved changes asked about. - WF-11 The engine: a workflow’s graph runs on ADK’s workflow engine (
google.golang.org/adk/v2/workflow), in an orchestration session of its own kept on disk (<sessions>/flows/*.events.jsonl), so a paused run can be resumed (Workspace.compileFlow,runFlow). Each step is a node; a step fed by several gets a join before it, and several last steps one end join. Steps on parallel branches run side by side, except that agent steps (turns in the chat) take turns. An agent step’s node runs a turn of ours in the chat’s session (WF-04), so it keeps its transcript line, hooks, guardrails, gates and checkpoints. - WF-12 Control flow (BPMN’s tokens;
Workspace.schedule): a step runs once every link into it is taken or ruled out and one is taken (a step fed by none: at once); steps that can run go side by side. When a step succeeds, its links out are decided: an exclusive gateway takes its first link (as listed) whose condition holds, else its default link; an inclusive gateway, and any other step, every link without a condition and every one whose condition holds, the default when no conditional one is; a parallel gateway every link (no conditions). The links not taken are ruled out, and so is a step all of whose links in are: it’s skipped (a flow step event,skipped) and its links out are ruled out too, so a step after an exclusive choice runs once with the branch that ran, and one after an inclusive one waits for every branch taken. A step that fails takes its error link instead (its error as the next step’s input, its other links ruled out); without one the run stops, its other running steps stopped. A step’s retry (attempts, the first wait, doubled each time up to a minute; said in the chat) and timeout apply before that. Gateways pass their input on. Each step runs through ADK’sRunNode, its result kept in the orchestration session. - WF-13 Data: a step’s result is its text and, when it has some, its data: the JSON an agent or a script answered with (alone, or in a code fence), a
.jsonfile’s, an extract step’s fields. An extract step has the suggestions model pull itsfields(a name, a type: string, number, boolean or list; a description) out of its input; each is checked against its type (nullwhen the text doesn’t give it). Conditions on links are CEL (when) or plain English judged by the suggestions model on the step’s output (if, a model call each). CEL seesoutput(this step’s{text, data}),input(the run’s) andsteps(each finished step’s, by ID:steps.lookup.data.total,steps["look-up"]for an ID with a hyphen); a condition must be true or false. Templates{{ expression }}in a step’s instructions, path and arguments are filled in as it starts (text as it is, anything else as JSON). Expressions and templates are checked when the file is read (WF-02). - WF-14 Calls. Their values take templates (WF-13) and
{secrets:key}, put in as the call is made (never in the workflow’s events) and masked wherever they’d show (8 characters or more, as every secret). mcp_call callstoolon the workspace’s MCP serverserverwitharguments(an object; a string that is one template alone keeps its value’s type), approved as the agent’s MCP calls are (the server’sauto_approve, deny and ask rules); its text is the tool’s text content, its data the structured content (or the JSON in its text); a tool that reports an error fails the step. api_call makes an HTTP request:method(GET by default),url,query,headers, andbody(text) orjson(a value, sent as JSON); a POST, PUT or PATCH with neither sends the step’s input.authisbearer(a token),basic,api_key(a name and value, in a header or the query) oroauth2(client credentials: token URL, client ID and secret, scopes, audience; the token is kept until it expires). Its data is{status, headers, body}(the body parsed when it’s JSON); a status of 400 or more fails the step. a2a_call sends a message (its instructions, else its input) to the remote agent whose card is aturl(ADK’s A2A remote agent, a2a-go), withheaderson its requests; its text is the agent’s reply. Every request (the token URL too, and each host an agent card says it answers at) goes through web_fetch’s guards: the web on ([web] enabled,sandbox.allow_network), public addresses only unlessweb.allow_private,web.deny_domains, redirects only on the same host, and approval per host (a permission rule,web.allow_domains, or asked); an A2A call reaches no host it wasn’t approved for. - WF-15 Calling workflows, repeating steps: a workflow step runs the workflow it names on its input, its steps in the same run (their flow step events name that workflow); its result is what that workflow’s last steps produced (one’s whole result, or their texts joined). Workflows calling each other in a loop, or calling one that isn’t there, have that as their problem (
flows.CallLoops,Workspace.Workflows), and calls nest 8 deep at most. Any step but a gateway can repeat:for_each(a CEL expression giving a list) runs it once per item,concurrencyat a time (4 by default, 32 at most; agent steps still take turns), withitemandindexin its templates and conditions; its text is each run’s under its number, its data the list of each run’s data (or text); an item that fails fails the step. A step in a called workflow runs directly (not throughRunNode): a resumed run replays the call step once it has finished, not its parts. - WF-16 People and time: a human_task step pauses its run until someone answers its
form(A2UI v0.9:components, flat, one with the idroot, anddata; WF-18), itsinstructionsthe message shown above it; a timer step pauses it forwait(a Go duration) oruntil(an RFC 3339 time), both templates. A paused run keeps its state in its orchestration session and a record (<storage>/flows/runs/<run>.json: the workflow, the file’s hash, the run’s chat, its origin, its input, what it waits on, and the finished steps’ results, which a resumed run replays: ADK’s own replay misses them, since its paths change on resume); its flow step event iswaiting. Answering (the button’s action name asaction, with its context’s values) resumes the run in the background in the chat it started in, the answer the step’s output (its data the object, its textkey: valuelines); a workflow changed since the run paused isn’t resumed (ResumeFlow). The service resumes runs whose timers are due (its scheduler’s loop,DueTimers). Human tasks and timers pause only the top-level workflow: not inside a called workflow or a repeated step. - WF-17 Over the API, and from workers:
ListHumanTasks(one workspace’s, or every one’s; withalerts, the user’s[ui] task_alerts),WatchHumanTasks(the same list as a stream: at once, then each time it changes; the page follows it, an older service is asked every 10 s) andAnswerHumanTask. A task’s form goes out as A2UI v0.9’s server-to-client messages (a2ui_json:createSurfacewith the task’s ID as the surface’s and the basic catalog,updateComponents,updateDataModel), so any A2UI renderer can draw it; its templates ({{ }}in its data and components) are filled from the run as the step starts (fillForm; a template alone keeps its value’s type). An answer is A2UI’s action message (action_json) with the surface’s data model (data_json), and the service checks it (pkg/a2ui,Workspace.CheckAnswer): the button must be one of the form’s with that action, the context only its values and what they resolve to, and every check of the components shown (templates over lists included) must pass, with the catalog’s functions in Go; otherwiseANSWER_INVALIDwith the problems.TASK_NOT_FOUNDfor one answered or gone,NOT_ANSWERABLEfor a timer; the resumed run comes back as a background run, and gets the action’s name asactionwith its context’s values. A worker’sworkflowfront matter field runs that workflow (its body, optional then, is the input) under the worker’s permissions, in its run’s chat; the worker form chooses it. - WF-18 Answering tasks (
a2ui/): forms (read from the service’s messages,parseMessages) are drawn by the page’s own renderer with the app’s controls (Text, Image, Icon, Row, Column, List, Card, Tabs, Modal, Divider, Button, TextField, CheckBox, ChoicePicker, Slider, DateTimeInput), bound to the form’s data by JSON Pointer, with the basic catalog’s checks and functions (required, regex, length, numeric, email, and, or, not, formatString, formatNumber, pluralize); nothing in a form runs as code or HTML, and images are https or inline only. The page’s checks are for feedback as you type; the service’s are the ones that count. The canvas edits a human task’s form as JSON with a live preview (Draft writes one from a description). A waiting task shows under its run on the canvas; across workspaces, the status bar shows how many wait and opens the tasks dialog (a list when there are several, the chosen task’s form, Later). New tasks are announced as[ui] task_alertssays (Settings › Appearance › Workflow tasks): notify (the default: a snackbar with Open, or a system notification while the window isn’t in front), open (the dialog at once) or off. With no app window open, the tray announces them (spec_electron_038 EL-110): a dot on its icon, N workflow tasks waiting in its menu, and the desktop app’s small tasks window. - WF-10 Run (a saved workflow without problems) asks for an optional input and runs it in the background as
/name input(StartBackground), in a chat of its own. The run goes on with Studio closed and the user does other work: the canvas follows the run’s receipt (WF-19), not the run itself. Opening a workflow while a run of it goes (started on the canvas, in a chat or by a worker) shows that run; the workflow list marks it (its state, steps done, cost). On the canvas each step is marked running, done, failed, waiting or skipped, with what its run took and spent under its title, the link into a running step animated, a selected step’s output or error shown. Under the canvas, the run panel: its state, date, duration, cost and tokens; an approval its background run waits on (the chat’s approval card, answered there), or the human task it waits on; Steps (a row a step’s run: when it started, how long it took, its tries, model, tokens in and out, cost, its error; the run’s totals), Events (the run’s events as they happen) and Stream (the text a chosen step’s turns streamed, live); Copy the receipt; Stop (StopWorkflowRun, wherever the run was started); folded to its header line, its height set by dragging its top edge (remembered), or closed (the run goes on). Past runs (the history button) lists the workflow’s receipts (date, state, duration, cost, how it was started) and shows one in the panel. - WF-19 Run receipts (
pkg/engine/flowreceipt.go): every run of a workflow, wherever it was started, keeps a receipt in<storage>/flows/receipts/<run>.json, written as the run goes (and every minute while nothing changes): its envelope (workflow, workspace, how it was started: chat, background or worker, its chat, its input, when it started and ended, its state and error, what it produced) and routing slip (each step’s run in order: its ID, title, type, state, start and end, tries, its agent, the models its calls used, its tokens and cost, the start of its output, the text its turns streamed (64 KiB), its error), its events (the latest 1,000: started, resumed, each step’s state, tool calls, notices) and its totals (the steps’ tokens and cost, every model used). Each step’s spending is metered as it goes (stepMeter): its turns’ usage on the chat, a rule’s judge’s and an extract step’s model calls priced as any model’s; a called workflow’s steps add to the call step’s. A paused run’s receipt says waiting and carries on when it resumes; a stopped run’s steps that were running end with it. 2026-10-18: every decision on a step’s actions is on the receipt too, as an event of kind approval with its step: what the auto reviewer allowed or denied and why, what a rule, a person, or a worker’s permissions decided (tools.WithDecisionObserver, the same decisions the audit log records). A receipt that says running, that this process isn’t running and that hasn’t been written for 3 minutes was lost (the service stopped while it ran) and says so. The newest 500 are kept. Step events carry their run’s ID (FlowStep.run), and a background run says which workflow run it runs (BackgroundRun.workflow_run). Over the API:ListWorkflowRuns(summaries: without events or steps’ text; one workspace’s or every one’s, one workflow’s or every one’s),GetWorkflowRun,WatchWorkflowRun(the receipt, then again each time it changes, until the run ends or pauses),WatchWorkflowRuns(the runs going, waiting and ended in the last 10 minutes, at once and each time one starts, changes state or ends; with the background run following each and the approvals it waits on) andStopWorkflowRun(RUN_NOT_FOUNDwhen it isn’t running). The page followsWatchWorkflowRunseverywhere (an older service: asked every 10 s): the status bar shows a run going (/name 2/5, or how many) and a failed one not yet looked at, each opening on its canvas; a run that fails is announced wherever the user is (a snackbar with Open, or a system notification while the window isn’t in front), and one started from the app that finishes or is stopped briefly; a run shown on a canvas isn’t announced again, and a worker’s run that succeeds says nothing (its worker keeps the record). A worker running a workflow shows its run’s progress with Open on the canvas, and its view follows a run that’s going when it’s shown again. - WF-20 Schedules: a workflow’s settings have its Schedule (
WorkflowSchedule): each schedule is a worker that runs it (a deterministic worker: the workflow’s steps exactly, its input the worker’s prompt), shown with when it runs next, switched on and off, changed (when, and the input) and removed there; Add a schedule takes when (in words or cron) and an optional input, and creates the worker (<workflow>-schedule, then-2, …) and enables it. What the worker may do and its limits are set in Studio › Workers. A workflow not saved yet (or with unsaved changes) can’t be scheduled. 2026-10-18: a schedule’s worker may do, without asking, what the workflow’s steps do (schedulePermissions): write the files its save steps save (a templated path: its folder), reach its service calls’ hosts, use its MCP steps’ tools, and browse (web:*) for its browser steps; the schedule says so. A workflow whose first step works on the input (an agent, refine, summarize or browser step nothing links to) warns when the schedule has none. Removing a schedule while its run goes asks, then stops the run and removes it. - WF-21 Its permission mode (2026-10-18, the owner’s request): a workflow’s steps run in its own permission mode (
mode:in the file;flows.ModeOf), auto unless it names another:auto,accept-edits,default,dont-askorbypass(plan isn’t one; another value is the file’s problem). It’s chosen when the workflow is made (New workflow asks, auto first) and in its settings, and the file says it only when it isn’t auto. The workspace’s mode is the limit the user sets (api.Looser, as an agent’s mode, BGA-50): a workflow’s mode looser than the workspace’s is held back, its steps run in the workspace’s, and its receipt says so (an eventheld: <asked>: <applied>, which the page words); a stricter one, or one level with it (accept-edits and auto), applies; bypass also needs the OS sandbox. A called workflow runs in its caller’s mode. The mode its steps ran in is on its receipt (FlowReceipt.mode,WorkflowRun.mode), in the run panel’s summary and in the copied receipt. The settings say when the workspace holds the chosen mode back.
Steps run one at a time, even on branches that could run side by side. A browser step drives the agent’s own browser, not the app’s web tabs. The step types email and data aren’t built. Human tasks and timers pause only the top-level workflow, not a called one or a repeated step; retries are counted in memory, so a resumed run starts them again. A run’s receipt meters what its steps’ turns and judges spend; a model call a tool makes inside a turn (a sub-agent’s) is counted with that turn, but tools that call models outside the engine’s usage (none built in) wouldn’t be. With no app window open, the tray announces a failed run within 10 s (spec_electron_038 EL-110); a run that finishes is only on its receipt.