State

A workflow’s state is a map[string]any 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 is recombined at fan-in.
  • How forEach branches 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.Delete / Clear / Keep / Transform keep it from accumulating.
  • The subgraph boundary as a function call: passing in into a child workflow and adopting its out back, via the typed NewSubgraph client.
  • Conventions, reserved keys, and the JSON-serialization rules every value must obey.

State as a Property Bag

State is a flat map[string]any keyed by field name. 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 Create or Run when the flow is started.
  • Named return values from task handlers.
  • Reducer-merged contributions from fan-out branches at fan-in points.

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 Foreman 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 map directly.

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.

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")
applicant := MyApplicant{}
_ = flow.Get("applicant", &applicant)

flow.SetString("status", "approved")
flow.Set("decision", MyDecision{Approved: true, Reason: "ok"})

Typed accessors (GetString, GetInt, GetFloat, GetBool, GetDuration, GetStrings) avoid the boilerplate cast; Get(key, target) unmarshals into a struct. Snapshot() returns the full state as a map 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.

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 state["employers"] = ["Acme", "Globex", "Initech"], this spawns three parallel verifyEmployment tasks. Each one sees:

  • The full state from the fan-out point, minus the source array. The Foreman strips the employers field from each branch’s local state at spawn, so an N-element forEach over a sub-chain doesn’t write N copies of the array across every step in every branch.
  • A new field employerName set to that branch’s single element ("Acme", "Globex", or "Initech" respectively).
  • Two read-only fields derived from the as alias: employerNameIndex int (0-based position in the source array) and employerNameCount 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, symmetric to the source-array strip on the way down. 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 still lives in the spawn step’s immutable snapshot, and the fan-in step rebuilds its own state from that snapshot — so the array is absent only inside the cohort and reappears in the fan-in step and everything downstream of it. A branch that wants the source array suppressed past the fan-in can call flow.Set("<forEach>", nil) in its task body; the replace reducer folds that over the spawn-step base at fan-in.

If the array is empty, no branches are spawned at all. When forEach is the only outgoing transition from a task, an empty array causes the flow to complete at that point — downstream tasks (including the fan-in target) are never reached. If you need a “zero-element” path, add a second unconditional or conditional transition.

State Merges at Fan-In via Reducers

When parallel branches converge on a shared successor node (marked with SetFanIn), the Foreman waits for every branch to complete and merges their state contributions before invoking the convergence task. The merge is per-field: each field is reduced independently by its own reducer, wired explicitly at graph-build time with graph.SetReducer(field, reducer).

graph.SetReducer("employmentFailures", workflow.ReducerAdd)    // sum numeric deltas
graph.SetReducer("messages",           workflow.ReducerAppend) // concatenate arrays
graph.SetReducer("verifiedFields",     workflow.ReducerUnion)  // dedupe across branches

A field with no registered reducer uses Replace — last-write-wins, resolved in fan-out order, not branch-completion order, so the result is deterministic. The full vocabulary (Append, Add, Min, Max, Union, Merge, And, Or, Concat) is covered in Reducers.

Tasks should write only the delta their branch produces, never the running total — otherwise the reducer compounds, and you get duplicates or a sum-of-sums. Reducers covers the full rules and this delta convention with worked examples.

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.

Four primitives on *workflow.Flow cover the common cleanup shapes. They mirror Go’s map builtins:

flow.Delete("blob", "scratch")            // drop the listed fields
flow.Clear()                               // drop every field
flow.Keep("resultHash")                    // drop everything except those listed
flow.Transform("subInput", "parentVar",    // clear all, then re-introduce
              "subInput2", "parentVar2")  //   the listed fields under new names

Each writes JSON null into the step’s changes for the dropped fields, and removes them from the in-memory state map so subsequent reads within the same task see them as absent. At the next step, the fields are gone.

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.Delete("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 as Has(name) == false, matching every other read accessor on Flow).
  • At fan-in, a dropped field interacts with the reducer. With the default replace reducer, the null wins like any other value and drops the field from merged state. With a registered reducer (Add, Append, Union, …), null folds to the reducer’s identity and the branch’s contribution is ignored — so a branch that has nothing to add can Delete rather than write a zero/empty value to avoid polluting the merge.
  • flow.Transform("newKey", "oldKey", ...) panics on an odd argument count. Old keys that were absent or already null are skipped, so the new key isn’t introduced as null.
  • 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; the flow.Transform / Delete / Clear primitives help when working over a derived copy of parent state.

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 explicit in/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, ssn, address)
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

AddTransitionWhen takes a boolean expression evaluated over state. The expression is what couples a task’s output to the next branch:

graph.AddTransitionWhen("decide", "autoApprove",      `decision == "approve"`)
graph.AddTransitionWhen("decide", "routeToManager",   `decision == "manager"`)
graph.AddTransitionWhen("decide", "rejectWithReason", `decision == "reject"`)

After decide completes, the Foreman evaluates each when expression against the merged state and takes every transition whose expression is true (fan-out happens naturally if more than one matches). 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 an AddTransitionOnError transition. The value is the serialized *errors.TracedError from the failing source task. The handler reads it (named parameter onErr *errors.TracedError) to inspect what went wrong before deciding how to proceed.
  • The as alias of a forEach transition — e.g. employerName in AddTransitionForEach("submit", "verifyEmployment", "employers", "employerName") — is set per-branch by the Foreman. It does not exist in parent state.
  • messages and pendingToolCalls — used by the built-in ChatLoop workflow (LLM integration) for the conversation and the per-turn tool-call queue. Avoid these names in your own graphs to keep ChatLoop subgraphs composable.

The framework does not otherwise reserve names. Anything else is yours.

Visibility and Debugging

State is the audit trail. Two 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 nested SubHistory.
  • 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

  • Returning a running total at fan-in. A branch that returns sumX = 7 (the new total) instead of sumXOut = 1 (this branch’s delta) compounds across branches. Always return deltas; the reducer accumulates.
  • Forgetting to SetFanIn on the convergence node. Without it, parallel branches don’t merge — they each carry forward independently and the graph validator rejects the missing pairing.
  • Reading from state inside a forEach branch 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.Pointer fail 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.Delete (or Clear / Keep) aggressively for anything over a few KB once you’re done with it.
  • Forgetting SetReducer on an accumulating fan-in field. A field that multiple branches contribute to defaults to last-write-wins, so all but one branch’s value is silently dropped. Wire it with graph.SetReducer(field, reducer) at graph-build time whenever the merge needs to add, append, union, or otherwise combine.

See Also

  • Reducers — the per-field merge rules wired with graph.SetReducer, and the delta convention.
  • 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 reviewJoin with a SetReducer-wired employmentFailures field, a forEach over employers with employerName as the alias, an IdentityVerification subgraph invoked via flow.Subgraph, an error handler reading onErr.
  • Package workflow — the *Flow carrier’s API, including the Get*/Set* typed accessors and the Delete / Clear / Keep / Transform primitives.
  • Foreman core service — the engine that persists state, applies reducers, and surfaces Snapshot / History / HistoryMermaid for inspection.