Patterns
For developers looking for how to express a common need. Each recipe is short and links to the guide that covers its building blocks.
An LLM Agent Loop
An agent thinks, calls tools, and repeats until it has an answer. That is a loop in the graph: Think
decides, and a switch either runs the tools or ends the flow.
g := workflow.NewGraph("Agent")
g.SetURL("Think", "https://agent/think")
g.SetURL("UseTools", "https://agent/use-tools")
g.AddTransitionSwitch("Think", "UseTools", "hasToolCalls")
g.AddTransitionSwitch("Think", workflow.End, "!hasToolCalls")
g.AddTransition("UseTools", "Think")graph LR
classDef task fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef term fill:#e5f4f3,color:#434343,stroke:#32a7c1
linkStyle default stroke:#32a7c1
t0["Think"]:::task
t0_switch{"switch"}:::term
t1["UseTools"]:::task
t1 --> t0
t0 --> t0_switch
t0_switch -->|"hasToolCalls"| t1
_start(( )):::term --> t0
t0_switch -->|"!hasToolCalls"| _end(( )):::termEvery turn of the loop is a durable step, so a crash resumes the agent where it was. For a conversation,
end the flow when the agent has answered, and start the next turn with
Continue: it carries the previous turn’s final state
(the conversation so far) into a new flow. Your application keeps the conversation as a chain of flow keys,
and continues the key the last Continue returned. To run several tool calls in parallel, have
Think emit them as an array and fan out with
forEach.
Human Approval
A task that needs a person’s decision calls f.Interrupt
with what the person needs to see. The flow waits, holding no worker, for as long as it takes. Your
application shows the request, and calls Resume with the
decision. The task then runs again and receives the decision as the return value of Interrupt. Route on
it with a switch, as the trip planner on the home page does.
Compensation When a Step Fails
When a later step fails, earlier steps often need undoing: a reservation released, a payment refunded. Use
an onError transition to route the failure to a
compensating task:
g := workflow.NewGraph("Order")
g.SetURL("Reserve", "https://shop/reserve")
g.SetURL("Charge", "https://shop/charge")
g.SetURL("Ship", "https://shop/ship")
g.SetURL("Release", "https://shop/release")
g.AddTransitionChain("Reserve", "Charge", "Ship", workflow.End)
g.AddTransitionOnError("Charge", "Release") // the charge failed: undo the reservation
g.AddTransition("Release", workflow.End)graph LR
classDef task fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef term fill:#e5f4f3,color:#434343,stroke:#32a7c1
linkStyle default stroke:#32a7c1
t0["Reserve"]:::task
t1["Charge"]:::task
t2["Ship"]:::task
t3["Release"]:::task
t0 --> t1
t1 --> t2
t1 -->|"onError"| t3
_start(( )):::term --> t0
t2 --> _end(( )):::term
t3 --> _end(( )):::termRelease reads what it needs to undo from the flow’s state, which still holds Reserve’s outputs, and
the error itself from the onErr field. Make compensating tasks
idempotent like any other.
Starting Later
Create starts a flow at once. To start the real work later, make the first task a gate that calls
f.Sleep and returns; the next step runs after the delay, and the
wait survives restarts:
func waitUntilMorning(ctx context.Context, f *workflow.Flow) error {
f.Sleep(8 * time.Hour)
return nil
}To start when something happens instead, make the first task call f.Interrupt, and Resume the flow
when it does. See Deferring Work.
Recurring Work
Dwarf has no built-in scheduler. Run the scheduler you already have (cron, a Kubernetes CronJob, a cloud
scheduler, or a ticker in your application) and have it call Create on its schedule. Each run is then
an ordinary durable flow, with its own history and retries.
A Deadline for the Whole Flow
A time budget limits each task, not the flow. To give a
flow as a whole a deadline, check the flow’s age in the tasks that matter with f.CreatedAt(), and route
to a timeout path when it is too old. See Timestamps.
To cap how long any flow may stay open, whatever its tasks do, set
SetFlowExpiration on the engine: a flow still
running or interrupted past that age is terminated. It applies to every flow alike, so set it well above the
longest a flow is meant to live.
Limiting Calls to One Service
Dwarf does not limit how many tasks call the same service at once: a single worker limit could not
express “64 concurrent LLM calls, but 1,000 database lookups”. Put the limit in your host, keyed on the
service, and let tasks back off with f.Retry
when the service pushes back:
var llmSlots = make(chan struct{}, 64) // at most 64 concurrent calls to the LLM provider
func (h *myHost) ExecuteTask(ctx context.Context, taskURL string, f *workflow.Flow) error {
if strings.HasPrefix(taskURL, "https://llm.internal/") {
select {
case llmSlots <- struct{}{}:
defer func() { <-llmSlots }()
case <-ctx.Done():
return ctx.Err()
}
}
return dispatch(ctx, taskURL, f)
}Next: Operating Dwarf.