State
A workflow’s state is a named property bag shared across every task in a flow. It is the only way tasks communicate with each other — there are no globals, no parent pointers, no implicit context beyond what the Foreman explicitly threads through state. Tasks read their inputs from named state fields, do their work, and write their outputs back as named state fields. Everything else about the flow’s progress — branching, fan-in merging, conditional routing, durability — is derived from state.
This page covers:
- How tasks read and write state.
- How state splits across parallel branches at fan-out, and how reducers combine writes to a field.
- How
forEachbranches see a single element of an array under an aliased name, plus its index and the cohort size. - How state flows down the line, and how
flow.Del/Clearkeep it from accumulating. - The subgraph boundary as a function call: passing
ininto a child workflow and adopting itsoutback, via the typedNewSubgraphclient. - Conventions, reserved keys, and the JSON-serialization rules every value must obey.
State as a Property Bag
State is flat, keyed by field name, and carried in a workflow.State value that holds each field as raw JSON and decodes it only when read. The Foreman persists it to SQL after every step — both the step’s input state and the changes it produced — so a complete flow’s history is recoverable from any point.
Values flow into state from three places:
- Initial state passed to
CreateorRunwhen the flow is started. - Named return values from task handlers, and their
flow.Setwrites. - Contributions to fields wired with a reducer, folded into the field’s value at every hand-off from one step to the next, including the fan-in of parallel branches.
Because state is persisted between every step, every value must be JSON-serializable. Functions, channels, unexported struct fields, and circular references can’t survive the round-trip. time.Time works but always comes back as a string in RFC 3339 format unless the consuming task uses a typed accessor.
How Tasks Read and Write State
A task handler is a normal Go function. The framework maps function parameter names to state field names for inputs, and named return value names to state field names for outputs:
func (svc *Service) VerifyCredit(ctx context.Context, flow *workflow.Flow, creditScore int) (creditVerified bool, err error) {
return creditScore >= 550, nil
}The generated task wrapper reads creditScore out of state to bind the creditScore int parameter, calls the function, and writes the return value back under the key creditVerified. The task body never touches the state directly.
An output with an Out suffix maps to the same field as the input it is named after (counter in, counterOut out), which expresses a read-modify-write; on a reduced field the pair returns only the contribution.
This convention is what makes graphs composable: the graph definition wires tasks together by the field names they exchange, not by any direct reference between handlers. Two tasks talking via creditScore and creditVerified don’t know about each other’s existence.
Reading a field as an input never consumes it: the field stays in state for every later task, and only flow.Del or flow.Clear removes it. A task need not have any outputs at all.
Every Returned Value Is Applied
Any value a task returns without an error is applied to the state, through the field’s reducer or the default Replace, on every successful return, including the yield path of a park. Zero values are applied like any other: returning false, 0 or nil for a field with no reducer resets it (a nil deletes it), and under a reducer a zero value folds like any other contribution. Returning an error applies nothing, neither the outputs nor any flow.Set the task made in that run.
The wrapper applies the outputs after the task body, with one flow.Set per declared output, so a returned value lands after anything the body set itself. Do not both flow.Set a field and return it as an output; use one or the other.
Typed Arguments the State Can Carry
Each typed argument is read and written as its own state field, by the JSON name of the generated In / Out struct field. So genservice rejects a task’s or workflow’s argument that the state cannot carry as written:
- a field that does not marshal to JSON, because it is unexported or tagged
json:"-"; - an embedded struct, rather than each state field declared explicitly;
- a field using the
,stringJSON option, since the state holds the bare value; - a JSON name ending in
!or+, which the engine reserves for its change records.
Imperative Access via *workflow.Flow
For cases where the named-parameter convention doesn’t fit — dynamic field names, conditional reads, working with the whole state at once — every task receives a *workflow.Flow carrier:
score := flow.GetInt("creditScore")
var applicant MyApplicant
ok, err := flow.Get("applicant", &applicant) // ok is false for an absent field
flow.SetString("status", "approved")
flow.Set("decision", MyDecision{Approved: true, Reason: "ok"})Typed accessors (GetString, GetInt, GetFloat, GetBool, GetDuration, GetStrings) avoid the boilerplate cast and return the zero value for an absent field; Get(key, target) unmarshals into a struct, and reports a type mismatch as an error where the typed accessors fail the step. Snapshot() returns the full state as a workflow.State for read-only inspection. Prefer named parameters and return values for everything that fits — they’re typed at compile time and they make the data dependencies visible in the function signature.
workflow.State is the type every state value travels in — a snapshot, a flow outcome’s State and InterruptPayload, a step’s State and Changes, and the flow’s baggage. It holds each field as raw JSON and decodes only what is read, so a large field carried through a task that never touches it costs no unmarshaling, and it carries the same typed accessors the carrier does:
total := outcome.State.GetInt("count")
var summary MySummary
err := outcome.State.Parse(&summary) // the whole state into one structBeing a struct rather than a map, it does not support indexing, len(), range, or comparison against nil. Len() counts fields, IsZero() reports an unpopulated state, Has(name) reports presence, and Names() iterates the field names.
State Splits at Fan-Out
When two or more transitions match the same source task — static branches declared with multiple AddTransition, dynamic branches declared with AddTransitionForEach, or any mix — the Foreman runs every target task in parallel. Each branch starts from the state as it stood when the fan-out source completed and accumulates its own changes independently.
graph.AddTransition("Submit", "VerifyCredit")
graph.AddTransition("Submit", "VerifyIdentity")
graph.AddTransition("Submit", "VerifyEmployment")All three verification branches see the same applicant, ssn, address, etc. that Submit left in state. Any field one branch writes is invisible to its sibling branches until they merge back at the fan-in node.
This isolation is what makes parallel branches safe: there is no shared mutable state across branches, no need for locks or coordination. Two branches can both write to verified bool without racing — they each persist their own value, and the reducer at fan-in decides what the merged result is.
Dynamic Fan-Out (forEach)
A forEach transition iterates over an array field in state and spawns one branch per element. Each branch sees the full state at the fan-out point plus its single element bound to the as alias:
graph.AddTransitionForEach("Submit", "VerifyEmployment", "employers", "employerName")If employers holds ["Acme", "Globex", "Initech"], this spawns three parallel VerifyEmployment tasks. Each one sees:
- The full state from the fan-out point, including the source array. The array is stored once and shared by every branch rather than copied into each, so a large array costs no more per branch than a small one.
- A new field
employerNameset to that branch’s single element ("Acme","Globex", or"Initech"respectively). - Two read-only fields derived from the
asalias:employerNameIndex int(0-based position in the source array) andemployerNameCount int(cohort size).
In the handler:
func (svc *Service) VerifyEmployment(ctx context.Context, flow *workflow.Flow, applicantName string, employerName string, employerNameIndex int, employerNameCount int) (employmentFailuresOut int, err error) {
// applicantName is the same in every branch.
// employerName is unique per branch — "Acme", "Globex", or "Initech".
// employerNameIndex / employerNameCount give the branch its ordinal context.
if applicantName == "" || employerName == "" {
return 1, nil
}
return 0, nil
}The alias name is what the graph builder passed as the last argument to AddTransitionForEach. The default is "item" if you pass an empty string. Pick a name that reads well in the handler — employerName is much better than item when the loop is over a list of employers. The injected <as>Index and <as>Count fields are present only for forEach fan-out, not static fan-out.
All three per-element fields — the as alias and its <as>Index / <as>Count companions — are dropped from the merged state at fan-in. They are per-element bookkeeping with no meaning past the cohort. A workflow that needs an element value downstream of the join must forward it under a different key inside the branch (e.g. flow.SetString("chosenEmployer", employerName)); the alias itself is gone past the fan-in.
The source array stays in state past the fan-in. A branch that wants it gone downstream (useful when it is very large and only the per-element results matter) calls flow.Set("employers", nil) in its task body; the field has no reducer, so the deletion replaces it at the fan-in.
If the array is empty, no branches are spawned, but the flow does not stop: the Foreman creates the fan-in step directly, and it runs on the source task’s own state with no branch contributions. A fan-in task must therefore tolerate a cohort of zero — a reduced field keeps its incoming value and any per-element output is absent — and the branch task must tolerate never running at all.
Reducers Combine Writes
A field wired with graph.SetReducer(field, reducer) combines its writes instead of replacing them. Every write to it is a contribution, folded through the reducer wherever one step hands its state to the next: the next step, the fan-in where parallel branches converge (marked with SetFanIn), the flow’s final state, and Continue. Each field is reduced independently by its own reducer.
graph.SetReducer("employmentFailures", workflow.ReducerAdd) // sum numeric contributions
graph.SetReducer("messages", workflow.ReducerAppend) // concatenate arrays
graph.SetReducer("verifiedFields", workflow.ReducerUnion) // dedupe across writesAt a fan-in the Foreman waits for every branch to complete and folds their contributions in branch order (the forEach array index or static declaration order), not branch-completion order, so the result is deterministic. A field with no registered reducer uses Replace, last write wins in that same order. The full vocabulary (Append, Add, Min, Max, Union, Merge, And, Or, Concat) is covered in Reducers.
Because a write is a contribution, a task writes only what it adds, never the value it read — otherwise the history is added again, and you get duplicates or a sum of sums. To trim or reset a reduced field, use flow.Overwrite. Reducers covers these contribution rules with worked examples, including Out pairs, parks and subgraphs.
State Flows Down the Line
There is no per-task lifecycle for state fields. A field written by an early task stays in state for every downstream task until it is explicitly overwritten or cleared. This is usually what you want — applicant set by the first task is available to every task that needs it, no plumbing required.
The downside: state accumulates. A long flow that fans out, fans in, processes, fans out again can carry forward fields no downstream task needs. Most of the time this is harmless (a few bytes per field, persisted once per step). But:
- Large values — file contents, model outputs, full conversation transcripts — get re-persisted in the input-state column of every subsequent step until they are removed.
- Sensitive values — bearer tokens, API keys, PII the workflow handled briefly — stay queryable in the history table forever unless cleared.
Two primitives on *workflow.Flow cover the cleanup shapes:
flow.Del("blob", "scratch") // drop the listed fields
flow.Clear() // drop every fieldEach records the deletion in the step’s changes and removes the fields from the task’s view of the state, so subsequent reads within the same task see them as absent. At the next step, the fields are gone. Del is flow.Overwrite with nil, so it removes a field whether or not it has a reducer.
func (svc *Service) ProcessLargeBlob(ctx context.Context, flow *workflow.Flow, blob []byte) (resultHash string, err error) {
h := sha256.Sum256(blob)
// We're done with the blob; downstream tasks only need the hash.
flow.Del("blob")
return hex.EncodeToString(h[:]), nil
}A few subtleties to know:
- The original input state of the step that called the primitive is untouched in the history table. The drop is recorded as a change, so the audit trail still shows what the field looked like when this task started — useful for debugging.
flow.Has(name)reports a cleared slot as absent for the remainder of the task body (a JSON null value reads asHas(name) == false, matching every other read accessor onFlow).- Inside a fan-out branch, a reduced field cannot be deleted. A
DelorClearof a field with a reducer fails the step there, because a reset in one parallel branch has no defined meaning for the others; drop it before the fan-out or after its fan-in. A branch that has nothing to add to a reduced field writes nil, or returns nil for it, which contributes nothing. A field with no reducer can be deleted anywhere, and the deletion replaces it at the fan-in like any other write. - Renaming a field is a snapshot, a clear, and explicit re-sets — there is no rename primitive. Read what you want to carry off
flow.Snapshot(),flow.Clear(), thenflow.Setit under the new name. - If you only want to clear something for one task without erasing it from downstream history, the right move is usually to structure the workflow so the field is never written above the task that uses it — e.g. let a parent task fetch the blob, hash it, store the hash, and never write the blob back up.
A good rule of thumb: drop large values as soon as the producing task is done with them. Workflow state is durable storage; treat it the way you’d treat any other persisted store.
Subgraph Boundaries Are Function Calls
A subgraph is a child workflow invoked from inside a task body. There is no static subgraph node — the parent graph never models a child workflow directly. A regular task calls flow.Subgraph(workflowURL, in, &out), parks while the child runs, and adopts the result on re-entry. The boundary is a function call: only the explicit in you pass crosses into the child, and only the child’s final state crosses back into out. Nothing is auto-inherited in either direction.
func (svc *Service) RunIdentityVerification(ctx context.Context, flow *workflow.Flow, ssn string, address string) (identityVerified bool, err error) {
var out creditflowapi.IdentityVerificationOut
yield, err := flow.Subgraph(creditflowapi.IdentityVerification.URL(), creditflowapi.IdentityVerificationIn{
SSN: ssn, // only the fields you set cross into the child
Address: address,
}, &out)
if yield {
return false, nil // first pass: parked, child running
}
if err != nil {
return false, errors.Trace(err)
}
return out.IdentityVerified, nil // adopt what the parent wants from the child's outputs
}Because the parent picks exactly what goes in and what comes back, state hygiene at the boundary is now a matter of choosing what to put in in and what to read from out — not of scrubbing the parent’s state with separate adapter tasks. A nil input means “no arguments” (the child starts with an empty state); pass flow.Snapshot() to hand the child the parent’s full state. When the reshape is non-trivial, build in with the names the child expects and read out with the names the child wrote — reading fields off a flow.Snapshot() and setting them under the child’s names is the whole operation.
If the child fails, the failure surfaces as the err return, so the calling task can flow.Retry (re-running the child), route, or propagate — rather than only cascading a flow failure. Parking the calling task and re-executing it once the child returns is the yield-and-re-enter pattern that Subgraph shares with Interrupt and Retry.
A Task Is Only Ever a Node in a Graph
There is no way to invoke a single task as a standalone unit. A task is only ever a node in a graph; the independently-invocable, engine-backed unit is the workflow. That leaves exactly two ways to compose, distinguished by state exposure:
- Graph composition — make task B a node in the same graph. It shares the parent’s full state (the tightest coupling). Use this when B is a genuine step of this workflow that speaks its state vocabulary.
- Subgraph of a declared workflow — invoke B’s workflow via
flow.Subgraph. State is isolated to the explicitin/out. Use this when B is a reusable or foreign unit you would rather hand explicit arguments than your whole state.
To make a single task callable on its own, declare a one-node workflow around it and invoke that. The apparent duplication is the point: the author declares intent rather than auto-promoting an internal step into a callable unit. When two graphs merely share logic, share a Go helper rather than reaching across the boundary.
Prefer the Typed NewSubgraph Client
Each microservice’s generated *api package exposes NewSubgraph(flow), which wraps flow.Subgraph with typed In/Out per workflow endpoint:
identityVerified, yield, err := creditflowapi.NewSubgraph(flow).IdentityVerification(ctx, applicantName, ssn, address, phone)
if yield || err != nil {
return identityVerified, err
}Each method mirrors the workflow’s typed arguments with a yield bool inserted before err. The raw flow.Subgraph remains the escape hatch for dynamic dispatch (a computed URL, or a service whose *api package you don’t import). The generated Executor client is for tests only — never call it, or foremanapi, from a task body.
The typed MyWorkflowIn / MyWorkflowOut structs declared via sub.Workflow are documentation and OpenAPI surface, not runtime contracts. The subgraph’s public API is the set of field names it reads from in and writes into its final state; changing internal field names inside the subgraph is a private refactor as long as that contract holds.
Reading State Across Transitions: Conditional Routing
AddTransitionSwitch and AddTransitionWhen take a boolean expression evaluated over state. The expression is what couples a task’s output to the next branch:
graph.AddTransitionSwitch("Decide", "AutoApprove", `decision == "approve"`)
graph.AddTransitionSwitch("Decide", "RouteToManager", `decision == "manager"`)
graph.AddTransitionSwitch("Decide", "RejectWithReason", "true") // default branchAfter Decide completes, the Foreman evaluates the expressions against its state. A Switch follows the first transition whose expression is true, so exactly one branch runs; a When follows every transition whose expression is true, so several matches fan out in parallel and need a SetFanIn downstream. The expression can reference any state field — including ones set by tasks far upstream of Decide — by name. This is conditional routing without any plumbing on the task side: Decide just writes a decision field, and the graph decides what that means.
Reserved State Keys
A few keys carry framework meaning:
onErr— set by the Foreman on the target task of anAddTransitionOnErrortransition. The value is the serialized*errors.TracedErrorfrom the failing source task. The handler reads it (named parameteronErr *errors.TracedError) to inspect what went wrong before deciding how to proceed. When the flow was stopped by a gracefulCancel,workflow.IsCancelled(onErr)is true, which lets the handler compensate for the cancellation rather than treat it as a failure.- The
asalias of aforEachtransition and its<as>Index/<as>Countcompanions — e.g.employerNameinAddTransitionForEach("Submit", "VerifyEmployment", "employers", "employerName")— are set per-branch by the Foreman and dropped at the fan-in. They do not exist outside the cohort.
Field names must not end in ! or +, which the engine reserves for its change records. The framework does not otherwise reserve names. A subgraph such as the built-in ChatLoop (LLM integration) runs on its own isolated state, so its internal field names never collide with yours.
Visibility and Debugging
State is the audit trail. Endpoints on the Foreman make it inspectable at any point:
Snapshot(flowKey)— current state of a running or terminated flow.History(flowKey)— per-step records, each with the step’s input state, the changes it produced, status, and any error. Subgraph steps include a nestedSubHistory.HistoryMermaid(flowKey)— renders the executed graph as a Mermaid diagram annotated with each step’s status.
When debugging a workflow that does the wrong thing, the right first move is almost always History — it tells you exactly what state every task saw and exactly what it wrote back.
Common Pitfalls
- Writing the value you read to a reduced field. A task that returns
totalOut = 7(the running total it read plus its own 1) instead oftotalOut = 1(its contribution) adds the total again, in a straight line as much as at a fan-in. Return only what the task adds; the reducer accumulates. On a path that adds nothing, including a park’syieldpath, return nil or the reducer’s no-op value. - Both setting and returning a field. The returned value is applied after the task body, so it overwrites a
flow.Setof the same field, or on a reduced field adds to it. Use one or the other. - Forgetting to
SetFanInon the convergence node. A graph that fans out without a fan-in compiles, and the graph validator rejects it when a flow is created. - Reading from state inside a
forEachbranch and assuming you see other branches’ results. You don’t. Sibling branches are invisible until fan-in. - Writing non-JSON-serializable values. Channels, functions, and
unsafe.Pointerfail silently on the persistence round-trip. Stick to plain data. - Carrying large blobs forward. The blob lands in the input-state column of every subsequent step until it’s cleared. Use
flow.Del(orClear) aggressively for anything over a few KB once you’re done with it. - Forgetting
SetReduceron an accumulating field. A field that multiple branches or steps contribute to defaults to last-write-wins, so all but one write is silently dropped. Wire it withgraph.SetReducer(field, reducer)at graph-build time whenever the merge needs to add, append, union, or otherwise combine.
See Also
- Reducers — the per-field rules wired with
graph.SetReducer, and how to write contributions. - Building Agentic Workflows — the tutorial that ties tasks, graphs, transitions, and state together end-to-end.
- Credit Flow example — every pattern on this page in one cohesive graph: parallel fan-out into
ReviewJoinwith aSetReducer-wiredemploymentFailuresfield, aforEachoveremployerswithemployerNameas the alias, anIdentityVerificationsubgraph invoked viaflow.Subgraph, an error handler readingonErr. - Package
workflow— the*Flowcarrier’s API, including theGet*/Set*typed accessors and theDel/Clearprimitives. - Foreman core service — the engine that persists state, applies reducers, and surfaces
Snapshot/History/HistoryMermaidfor inspection.