Agentic Workflows
Agentic workflows allow microservices to collaborate on multi-step processes that may branch, fan out, pause for external input, and recover from failures - all without the participants having to know about each other or the overall flow. Microbus models these workflows as directed graphs whose execution is managed by the Foreman core service.
Workflows
A workflow is a directed graph that describes how tasks are connected. Each node is either a task or a reference to another workflow (a subgraph). Edges between nodes are transitions that determine which path is taken after a task completes.
Workflow graphs are defined in code by a microservice and exposed as an endpoint. The graph is fetched once when a flow is created and stored alongside it, so the definition is immutable for the lifetime of that flow.
Tasks
A task is a functional endpoint that performs a single step of a workflow. It receives a *workflow.Flow carrier that provides access to the workflow’s shared state and control signals. Tasks read input from the state, do their work, write output back to the state, and return. They have no knowledge of what comes before or after them.
Tasks are registered on port :428 by default and are built using a dedicated coding agent skill, just like any other microservice feature.
A task that needs to run Python - ML inference, scientific computing, a data-frame transform, or any library that has no usable Go equivalent - spawns its own Python subprocess via the pyvenv library and delegates the computation to it. The task itself stays in Go and reads the result back into the flow’s state. The Python integration wrapper pattern is what lets durable workflows drive long-running Python calls without holding bus connections open.
State
A workflow’s state is a map[string]any key-value bag that is shared across all tasks in a flow. Tasks read their inputs from the state and write their outputs back to it. The state is passed from step to step and persisted in a SQL database by the Foreman. Each step records both its input state and the changes it produced, creating a full history of the flow’s execution.
The State page gives the full treatment: how state splits across parallel branches at fan-out, how forEach branches see a single element of an array (plus its index and the cohort size) under an aliased name, how to use flow.Delete / Clear / Keep / Transform to keep state from accumulating large or sensitive values, and how a subgraph’s input and out scope what state crosses the function-call boundary.
Transitions
Transitions are the edges of the workflow graph. They connect tasks and control the order of execution. There are eight types of transitions, each with different routing semantics.
Unconditional
An unconditional transition is always taken after the source task completes. It is the simplest form of transition and is used for straightforward sequential flows.
Conditional
A conditional transition (AddTransitionWhen) is taken only when a boolean expression over the flow’s state evaluates to true. Sibling conditional transitions are evaluated independently and all matching ones fire, so they fan out in parallel — the right shape when you genuinely want multiple branches to run.
Switch
A switch transition (AddTransitionSwitch) is first-match-wins routing: the Foreman evaluates the source’s switch branches in registration order and follows only the first whose expression matches. Because exactly one branch runs, no fan-in is needed. Prefer Switch over conditional transitions for any routing where exactly one branch should run — the validator enforces mutual exclusivity, so two branches can’t accidentally both fire. Use when="true" as a final default branch.
Goto
A goto transition is taken only when the source task explicitly requests to jump to a specific target. Unlike conditional transitions that are evaluated by the Foreman based on state, goto transitions are controlled imperatively by the task itself. This is useful for loops or retry patterns where the task decides at runtime whether to repeat or move on.
Fan-Out
When multiple transitions match from a single task, all of their targets execute in parallel. This is fan-out, and it happens naturally whether the transitions are unconditional, conditional, for each, or a mix.
Dynamic Fan-Out
A forEach transition iterates over an array field in the state and spawns one parallel branch per element. This is useful when the number of parallel branches is not known at design time - for example, verifying each employer in a list of variable length.
Fan-In
When parallel branches converge on a shared successor, the Foreman waits for all branches to complete and merges their state changes before continuing. This is fan-in. Reducers control how conflicting changes to the same state field are combined and are wired explicitly at graph-build time with graph.SetReducer(field, reducer) (ReducerAdd, ReducerAppend, ReducerUnion, ReducerMerge, ReducerAnd, ReducerOr, ReducerConcat, ReducerMin, ReducerMax); fields with no registered reducer default to last-write-wins. Contributions are merged in fan-out order (the forEach array index or static branch declaration order), not branch-completion order, so the result is deterministic.
Error
An error transition is taken when the source task returns an error. It diverts the flow to a handler task instead of failing the whole flow. The serialized error is placed in the target task’s state under the key onErr, so the handler can inspect what went wrong and decide how to proceed - compensate, log, or retry through an alternative path. Error transitions cannot be combined with when, forEach, or goto.
Timeout
A timeout transition is a specialized error transition that fires only when the source task’s failure is a timeout - either the Foreman’s per-step time budget expired or the task handler’s own deadline did. Both surface as HTTP status 408 and are matched by the same transition. When a task has both a timeout transition and a plain error transition, the timeout transition wins on a timeout and the plain error transition catches everything else.
OnError and OnTimeout transitions cannot loop back to the source task - the graph validator rejects self-targeted error transitions because they would retry indefinitely with no backoff budget. To express “retry the same task on timeout”, call flow.Retry(initialDelay, multiplier, maxIntervalDelay, giveUpAfter) in the task body, gated on the 408 status: if errors.StatusCode(err) == http.StatusRequestTimeout && flow.Retry(...). The giveUpAfter wall-clock horizon keeps the retry bounded by construction.
Control Signals
Tasks can issue control signals to influence the flow’s execution. Interrupt, Subgraph, and Retry share one mechanic — the task yields, the Foreman parks the step, and the same task is re-executed once its result is ready — the yield-and-re-enter pattern.
Goto
A task can goto another task to override normal transition routing and jump to a specific target. This is useful for loops or retry patterns where the task decides at runtime whether to repeat or move on.
Interrupt
A task can interrupt itself to pause the flow and signal that external input is needed - a human approval, a callback from a third-party system, or any event that cannot be awaited synchronously. The flow enters the interrupted status and remains parked until an external actor calls Resume with the required data.
Sleep
A task can sleep to delay the execution of the next step by a specified duration. The flow is parked and automatically resumed by the Foreman after the duration elapses.
Retry
A task can retry to re-execute itself on the next pass, preserving the changes it has made so far. This is useful for polling or incremental progress. flow.Retry(initialDelay, multiplier, maxIntervalDelay, giveUpAfter) is the single retry primitive: it returns true while the step is within its retry horizon and false once giveUpAfter of wall-clock since the step was first created is spent, and it carries no condition of its own — the task writes the retryable predicate explicitly in the surrounding if. To retry only on a timeout, gate on the 408 status (errors.StatusCode(err) == http.StatusRequestTimeout); for an unbounded poll, pass giveUpAfter <= 0 with zero delays.
Dynamic Subgraph
A task can launch a child workflow with flow.Subgraph(workflowURL, input, &out). This is the only kind of subgraph — there is no static subgraph node in the graph definition; a subgraph is always a function call from a task body. Only the explicit input you pass crosses into the child, and the child’s final state is unmarshaled into the out pointer the caller passes (not auto-merged into parent state). The step parks until the child completes, then the task is re-executed and reads its result from out — the same re-entry pattern as Interrupt. The call returns (yield, err): if yield { return ... } on the first pass, then on re-entry adopt fields from out or branch on err if the child failed.
The Foreman
The Foreman core service is the orchestration engine that ties everything together. It fetches workflow graphs, creates and persists flows, dispatches tasks to the appropriate microservices, evaluates transitions, merges state at fan-in points, and handles interrupts and recovery. The Foreman persists all state in a SQL database via the Sequel library, making workflows durable across restarts.
When more flows are ready than there are workers to run them, Priority and Fairness controls which flow runs first and keeps one busy tenant from crowding out the rest.
Worked Examples
- Credit Flow example — a mock credit-approval workflow that exercises most workflow features in one cohesive graph: parallel fan-out with a
SetReducer-wired add reducer at fan-in, aforEachover a runtime-sized array, aflow.Subgraphchild workflow, agoto-driven manual-review loop, and an error transition. - Building an LLM Workflow — promotes a synchronous
Chatcall to a durable workflow step: a parent graph whose task invokesChatLoopviaflow.Subgraph, feeding a downstreamDecidetask that conditionally fans out to outcome handlers based on the LLM’s verdict.