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(( )):::term

Every 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(( )):::term

Release 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.