Skip to main content
The orchestrator lets you compose multiple agents into pipelines. Each agent is independent and can have its own provider, tools, and memory.

Sequential pipeline

Steps run one after another. Each step receives the previous step’s output via {{.previous}}.
import "github.com/lioarce01/chainforge/pkg/orchestrator"

result, err := orchestrator.Sequential(ctx, "session",
    "Golang concurrency patterns",
    orchestrator.StepOf("research", researchAgent, "Research this topic: {{.input}}"),
    orchestrator.StepOf("write",    writerAgent,   "Write a blog post based on: {{.previous}}"),
    orchestrator.StepOf("review",   reviewAgent),  // no template — passes previous output verbatim
)
  • {{.input}} — the original input passed to Sequential
  • {{.previous}} — the output of the preceding step
  • The template argument is optional. Omitting it passes the previous step’s output verbatim.
  • Step failures wrap the step name: step "write": ...

Parallel fan-out

All agents run concurrently. Results are collected when all finish.
results, err := orchestrator.Parallel(ctx, "session",
    orchestrator.FanOf("pros",    proAgent,    "Analyze the pros of Go"),
    orchestrator.FanOf("cons",    conAgent,    "Analyze the cons of Go"),
    orchestrator.FanOf("summary", summaryAgent, "Summarize Go"),
)

// Check for any branch error in one call:
if err := results.FirstError(); err != nil {
    log.Println("a branch failed:", err)
}

// Look up a branch by name:
if r, ok := results.Get("pros"); ok {
    fmt.Println("pros:", r.Output)
}

// Get a map of all successful outputs:
outputs := results.Outputs() // map[string]string
Parallel always returns all results — a failed branch never cancels siblings. ParallelResults convenience methods:
MethodDescription
.Get(name)Returns the ParallelResult for a branch and whether it was found.
.FirstError()Returns the first non-nil branch error, or nil if all succeeded.
.Outputs()Returns map[string]string of branch name → output for successful branches only.
Existing for _, r := range results loops still compile — ParallelResults is []ParallelResult.

Router

A Router dispatches a message to one of several named agents. Useful for building supervisor patterns where different agents handle different types of requests.

Function-based routing

Pick the route with your own logic — zero LLM overhead:
router := orchestrator.NewRouter(
    func(ctx context.Context, input string) (string, error) {
        if strings.Contains(strings.ToLower(input), "code") {
            return "coder", nil
        }
        return "general", nil
    },
    orchestrator.RouteOf("coder",   "writes and debugs code",   coderAgent),
    orchestrator.RouteOf("general", "answers general questions", generalAgent),
)

result, err := router.Route(ctx, "session-1", userMessage)

LLM-based routing

Let a supervisor agent decide which route to use. The supervisor receives the input and a formatted list of available agents:
supervisor, _ := chainforge.NewAgent(
    chainforge.WithProvider(p),
    chainforge.WithModel("claude-haiku-4-5-20251001"), // cheap model for routing
    chainforge.WithSystemPrompt("You are a routing agent. Reply with only the route name."),
)

router := orchestrator.NewLLMRouter(supervisor,
    orchestrator.RouteOf("researcher", "searches and summarises information", researchAgent),
    orchestrator.RouteOf("coder",      "writes and debugs code",              coderAgent),
    orchestrator.RouteOf("analyst",    "analyses data and produces reports",  analystAgent),
)

result, err := router.Route(ctx, "session-1", userMessage)
The supervisor receives a structured prompt listing all routes with their descriptions and must respond with the exact route name.

Default route

Set a fallback route that is used when the picker returns an unrecognised name:
router := orchestrator.NewRouter(pick,
    orchestrator.RouteOf("coder",   "writes code",       coderAgent),
    orchestrator.RouteOf("general", "general questions", generalAgent),
).WithDefault("general") // fallback when picker returns unknown name
WithDefault is chainable and returns *Router. If the default route name itself is not registered, the error falls through normally.

Behaviour

  • Session namespacing — each route runs under sessionID:routeName, so agents maintain independent history across calls.
  • LLM response normalisation — the supervisor response is trimmed, lowercased, and stripped of quotes to handle common LLM formatting quirks.
  • Supervisor session — the LLM supervisor always uses the fixed session router:supervisor so routing decisions don’t pollute conversation history.
  • Unknown route error — returns a clear error listing available route names (skipped when a valid default route is set).

Combining patterns

Router composes naturally with Sequential and Parallel:
// Route first, then run a sequential pipeline on the result
router := orchestrator.NewLLMRouter(supervisor,
    orchestrator.RouteOf("deep-research", "thorough multi-step research", func() *chainforge.Agent {
        // This agent internally uses Sequential across research → summarise → cite
        return researchPipelineAgent
    }()),
    orchestrator.RouteOf("quick-answer", "fast single-turn answers", quickAgent),
)

Conditional branching

Run different agents depending on a predicate applied to the input:
result, err := orchestrator.Conditional(
    ctx, "session",
    userMessage,
    func(input string) bool {
        return strings.Contains(strings.ToLower(input), "code")
    },
    coderAgent,   // runs when predicate is true
    generalAgent, // runs when predicate is false (pass nil for a no-op)
)
Sessions are namespaced as sessionID:if and sessionID:else so each branch maintains independent memory. Passing nil as the else-agent returns the original input unchanged when the predicate is false.

Loops

Repeatedly run an agent while a condition holds, up to a maximum iteration count:
result, err := orchestrator.Loop(
    ctx, "session",
    initialInput,
    refineAgent, // receives previous output as next input
    func(iter int, output string) bool {
        // Keep refining until the output is "good enough".
        return !strings.Contains(output, "DONE") && iter < 5
    },
    10, // hard cap — loop stops after 10 iterations regardless
)
  • The condition function receives the current iteration index (0-based) and the latest output.
  • Each iteration runs under session sessionID:loop-N for isolated history.
  • When maxIter is exhausted the loop exits with the last output (no error).
  • Context cancellation propagates immediately from the inner agent call.

Streaming

Use RunStream for real-time output:
stream := agent.RunStream(ctx, "session-1", "Tell me a story.")
for event := range stream {
    switch event.Type {
    case core.StreamEventText:
        fmt.Print(event.TextDelta)
    case core.StreamEventDone:
        fmt.Println()
    case core.StreamEventError:
        fmt.Println("error:", event.Error)
    }
}

Human-in-the-Loop (HITL)

HITL gates tool calls behind human approval. Use it alongside any orchestration pattern — each individual agent can have its own HITL gateway:
import "github.com/lioarce01/chainforge/pkg/hitl"

// Only intercept destructive tools.
gw := hitl.OnlyTools(hitl.NewCLIGateway(os.Stdout, os.Stdin), "send_email", "delete_file")

agent, _ := chainforge.NewAgent(
    chainforge.WithProvider(p),
    chainforge.WithTools(emailTool, searchTool),
    chainforge.WithHITLGateway(gw),
)
See the HITL guide for all gateway implementations and patterns.