Orchestrating multiple agents

September 3, 2026 · View on GitHub

Orchestration is deciding which agents run, in what order, and how they decide what happens next. There are two main approaches, freely mixable:

  1. Let the LLM decide: give a capable agent tools and handoffs and let it plan.
  2. Orchestrate via code: you decide the flow; agents are well-scoped subroutines.

Orchestrating via LLM

An agent equipped with tools, handoffs and clear instructions can plan autonomously: research with tools, delegate specialist work, write results somewhere. This is the most flexible pattern. Tactics that pay off:

  1. Invest in prompts: state available tools, constraints, and how to operate.
  2. Monitor with tracing and iterate.
  3. Let the agent self-improve: loop, critique, retry (guardrails catch the failure modes).
  4. Use specialist agents that excel at one task over one generalist.
research := &agents.Agent{Name: "Researcher", Tools: []*agents.Tool{webLookup}}
writer := &agents.Agent{Name: "Writer", Instructions: agents.StaticInstructions("Write the final report.")}

planner := &agents.Agent{
	Name:         "Planner",
	Instructions: agents.StaticInstructions("Plan the work, research as needed, then hand off to the writer."),
	Tools:        []*agents.Tool{webLookup},
	Handoffs:     []agents.Handoff{agents.HandoffTo(writer)},
}

Agents as tools

Where a handoff transfers the conversation, AsTool keeps the orchestrator in charge: the sub-agent runs on just the input the orchestrator passes, returns its final output as the tool result, and the orchestrator continues.

spanish := &agents.Agent{Name: "spanish_agent", Instructions: agents.StaticInstructions("Translate the message to Spanish.")}
french := &agents.Agent{Name: "french_agent", Instructions: agents.StaticInstructions("Translate the message to French.")}

orchestrator := &agents.Agent{
	Name: "orchestrator",
	Instructions: agents.StaticInstructions(
		"You are a translation agent. Use the tools to translate; for multiple languages, call the relevant tools."),
	Tools: []*agents.Tool{
		spanish.AsTool(agents.AgentToolConfig{Name: "translate_to_spanish", Description: "Translate the user's message to Spanish"}),
		french.AsTool(agents.AgentToolConfig{Name: "translate_to_french", Description: "Translate the user's message to French"}),
	},
}

AgentToolConfig configures the tool surfaceName / Description, IsEnabled, NeedsApproval / NeedsApprovalFunc, CustomOutputExtractor (derive the string result from the nested *RunResult), FailureErrorFunction, OnStream, InputBuilder — every field on pkg.go.dev. Everything about the nested run itself goes through ModifyRunOptions:

sub.AsTool(agents.AgentToolConfig{
	Name: "specialist",
	ModifyRunOptions: func(o *agents.RunOptions) {
		o.Conversation.Session = sess // conversation state of its own
		o.Exec.MaxTurns = 5           // nested turn budget
	},
})

Streaming a nested run. Setting OnStream switches the nested run to streaming: every event (raw model deltas, run items, agent updates) is delivered as an AgentToolStreamEvent carrying the current nested agent and the originating tool call. Events dispatch from a background goroutine so a slow callback never stalls the run; a panic in the callback is recovered, and a canceled parent does not wait for the callback backlog.

Typed parameters. AgentAsTool[Params](agent, cfg) replaces the default {input: string} schema with one reflected from Params; both constructors validate the model's arguments against the advertised schema before the nested run starts, and a failure goes back to the calling model as a tool error (spec §2.7h). The arguments render into the nested input with a structured preamble and the JSON payload — or the full schema with InputBuilder: agents.AgentToolInputWithSchema, or through your own InputBuilder.

The nested run inherits the parent's model provider, model override, model settings, tracer and log configuration through the run context, so sub-agents need no provider of their own. Its spans join the parent's trace and its log records carry the sub-agent's name; its usage is tracked separately. If the model calls several agent-tools in one turn they run concurrently — like any other function tools.

State isolation. The nested run never inherits the parent's conversation state; give it a session of its own — or, explicitly, the parent's — through ModifyRunOptions (spec §2.8).

Without a CustomOutputExtractor, the tool result is the nested run's final output — as a string for plain-text agents, or the JSON payload for structured ones. When the final output is empty, it falls back to the last non-empty assistant message, then the last non-empty string tool output.

Human-in-the-loop through an agent tool. If a tool inside the sub-agent needs approval (Human-in-the-loop), the nested run pauses and its approval surfaces as the orchestrator run's own interruptionRunResult.Interruptions carries the nested tool's approval item. Approve or reject it on RunResult.State and ResumeRun as usual; the orchestrator continues the paused nested run (applying your decision) instead of restarting it, then finishes the parent turn. The paused nested state is serialized recursively inside the parent's RunState JSON, so a state persisted and resumed in another process also continues the nested run mid-approval — provided the agent registry passed to RunStateFromJSON contains every agent involved, including the sub-agents.

Orchestrating via code

Plain Go is often the clearest orchestrator — deterministic, testable, cheap:

// Chain: outline -> approve -> write
outline, err := agents.RunSync(ctx, outliner, topic, opts)
if err != nil { return err }

check, err := agents.RunSync(ctx, reviewer, outline.FinalOutputString(), opts)
if err != nil { return err }
if verdict, _ := agents.FinalOutputAs[Verdict](check); !verdict.Good {
	return fmt.Errorf("outline rejected: %s", verdict.Reason)
}

story, err := agents.RunSync(ctx, writer, outline.FinalOutputString(), opts)

Two more patterns worth naming:

  • Structured decisions: use OutputType to get a typed verdict you can branch on.
  • Chaining: feed one agent's output into the next.
  • Evaluate-and-retry: loop a worker and a critic agent until the critic passes.
  • Fan-out: Go makes parallel agents natural — run several agents.Run calls in goroutines (e.g. with errgroup) and join the results.
g, gctx := errgroup.WithContext(ctx)
results := make([]*agents.RunResult, len(questions))
for i, q := range questions {
	g.Go(func() error {
		res, err := agents.RunSync(gctx, analyst, q, opts)
		results[i] = res
		return err
	})
}
if err := g.Wait(); err != nil { /* … */ }

A runnable program is examples/multiagent (agent-as-tool) alongside examples/handoffs (control moves and does not come back).