Multi-Agent Teams
Teams let multiple AI agents collaborate on a shared task. Instead of one agent doing everything, you build specialist agents — a researcher, a writer, a reviewer — and let a team strategy coordinate how they work together.
This guide walks you through every concept from scratch, with complete, runnable examples.
Before You Start
Prerequisites:
- Go 1.24 or later
- An API key from any supported provider (OpenAI, Anthropic, Gemini, etc.)
Install Chronos:
go get github.com/spawn08/chronos
Key imports you’ll use throughout this guide:
import (
"context"
"os"
"github.com/spawn08/chronos/engine/graph"
"github.com/spawn08/chronos/engine/model"
"github.com/spawn08/chronos/sdk/agent"
"github.com/spawn08/chronos/sdk/protocol"
"github.com/spawn08/chronos/sdk/team"
)
Core Concepts
What Is an Agent?
An agent is a unit of work powered by an LLM. Each agent has a role, a system prompt that defines its behavior, and optionally, tools and capabilities.
Lightweight agents (model-only) need only a model — no graph, no storage, no database. This is the recommended way to build agents for team orchestration:
researcher, _ := agent.New("researcher", "Researcher").
Description("Researches topics and gathers facts").
WithModel(model.NewOpenAI(os.Getenv("OPENAI_API_KEY"))).
WithSystemPrompt("You are a research specialist. Given a topic, provide key facts.").
AddCapability("research").
Build()
Graph-based agents add durable execution with checkpointing, but are heavier — use them when you need workflows with multiple steps, interrupts, or persistence. See the StateGraph guide for details.
What Is a Team?
A team is a group of agents that work together using a strategy — the pattern that decides who runs, in what order, and how results are combined.
t := team.New("my-team", "My Team", team.StrategySequential)
t.AddAgent(researcher)
t.AddAgent(writer)
result, err := t.Run(ctx, graph.State{"message": "Write about renewable energy"})
What Is graph.State?
graph.State is simply map[string]any. It carries data between agents. You put data in, agents read and write keys, and the final state is your result.
input := graph.State{
"message": "Explain quantum computing",
"format": "article",
}
Four Strategies at a Glance
| Strategy | How It Works | Best For |
|---|---|---|
| Sequential | Agents run one after another in a pipeline | Content pipelines, multi-step processing |
| Parallel | All agents run at the same time, results merged | Independent analysis, multi-perspective work |
| Router | One agent is selected based on the input | Customer support, task classification |
| Coordinator | A supervisor agent decomposes the task and delegates | Complex projects, dynamic task planning |
Creating Agents
Every team needs agents. Here’s how to build them for each common role.
Minimal Agent (Just a Model)
The simplest agent — a model with a system prompt:
a, err := agent.New("helper", "Helper Agent").
WithModel(model.NewOpenAI(os.Getenv("OPENAI_API_KEY"))).
WithSystemPrompt("You are a helpful assistant.").
Build()
if err != nil {
log.Fatal(err)
}
Agent with Capabilities
Capabilities are tags that tell routers and coordinators what an agent is good at:
writer, _ := agent.New("writer", "Writer").
Description("Writes polished content from research notes").
WithModel(model.NewOpenAI(apiKey)).
WithSystemPrompt("You are a writing specialist. Produce clear, engaging prose.").
AddCapability("writing").
AddCapability("editing").
Build()
Agent with Tools
Agents can call tools (functions) during execution:
coder, _ := agent.New("coder", "Coder").
WithModel(model.NewOpenAI(apiKey)).
WithSystemPrompt("You are a Go programmer.").
AddTool(&tool.Definition{
Name: "run_tests",
Description: "Run the test suite",
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
Permission: tool.PermAllow,
Handler: func(ctx context.Context, args map[string]any) (any, error) {
return map[string]string{"status": "all tests passed"}, nil
},
}).
Build()
Using Different Providers
Each agent can use a different LLM provider:
// Agent 1: OpenAI GPT-4o
agent1, _ := agent.New("gpt-agent", "GPT Agent").
WithModel(model.NewOpenAI(os.Getenv("OPENAI_API_KEY"))).
Build()
// Agent 2: Anthropic Claude
agent2, _ := agent.New("claude-agent", "Claude Agent").
WithModel(model.NewAnthropic(os.Getenv("ANTHROPIC_API_KEY"))).
Build()
// Agent 3: Local Ollama
agent3, _ := agent.New("local-agent", "Local Agent").
WithModel(model.NewOllama("http://localhost:11434", "llama3.2")).
Build()
Strategy 1: Sequential (Pipeline)
Sequential runs agents one after another in a pipeline. Each agent receives the output of the previous agent and builds on it.
Input → [Agent A] → [Agent B] → [Agent C] → Result
When to Use
- Multi-step content creation (research → write → review)
- Data processing pipelines (extract → transform → validate)
- Any workflow where each step depends on the previous step
Basic Example
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/spawn08/chronos/engine/graph"
"github.com/spawn08/chronos/engine/model"
"github.com/spawn08/chronos/sdk/agent"
"github.com/spawn08/chronos/sdk/team"
)
func main() {
ctx := context.Background()
apiKey := os.Getenv("OPENAI_API_KEY")
// Step 1: Create specialist agents
researcher, _ := agent.New("researcher", "Researcher").
WithModel(model.NewOpenAI(apiKey)).
WithSystemPrompt("You are a researcher. Given a topic, provide 3-5 key facts.").
Build()
writer, _ := agent.New("writer", "Writer").
WithModel(model.NewOpenAI(apiKey)).
WithSystemPrompt("You are a writer. Take the research provided and write a short article.").
Build()
reviewer, _ := agent.New("reviewer", "Reviewer").
WithModel(model.NewOpenAI(apiKey)).
WithSystemPrompt("You are an editor. Review the article for clarity and accuracy.").
Build()
// Step 2: Create a sequential team
t := team.New("pipeline", "Content Pipeline", team.StrategySequential).
AddAgent(researcher).
AddAgent(writer).
AddAgent(reviewer)
// Step 3: Run the pipeline
result, err := t.Run(ctx, graph.State{
"message": "Write a short article about renewable energy trends",
})
if err != nil {
log.Fatal(err)
}
// Step 4: Read the final output
fmt.Println(result["response"])
}
How State Flows
The "message" key is the primary input. Each agent reads it, produces a "response", and the response becomes available as "_previous_response" for the next agent:
| Step | Agent | Reads | Writes |
|---|---|---|---|
| 1 | Researcher | message |
response (research notes) |
| 2 | Writer | message, _previous_response (research) |
response (article draft) |
| 3 | Reviewer | message, _previous_response (draft) |
response (final article) |
The final result["response"] contains the reviewer’s output.
Viewing Communication History
Every team records all inter-agent messages. Use this for debugging or observability:
for _, msg := range t.MessageHistory() {
fmt.Printf("[%s] %s → %s: %s\n", msg.Type, msg.From, msg.To, msg.Subject)
}
Strategy 2: Parallel (Fan-Out / Fan-In)
Parallel runs all agents at the same time on the same input, then merges their results into one output.
┌→ [Agent A] ─┐
Input ───┤→ [Agent B] ──┤──→ Merge → Result
└→ [Agent C] ─┘
When to Use
- Getting multiple perspectives on the same question
- Independent analyses that don’t depend on each other
- Speeding up work by doing things simultaneously
Basic Example
ctx := context.Background()
apiKey := os.Getenv("OPENAI_API_KEY")
optimist, _ := agent.New("optimist", "Optimist").
WithModel(model.NewOpenAI(apiKey)).
WithSystemPrompt("Analyze the topic with an optimistic perspective. Focus on opportunities.").
Build()
pessimist, _ := agent.New("pessimist", "Pessimist").
WithModel(model.NewOpenAI(apiKey)).
WithSystemPrompt("Analyze the topic with a critical perspective. Focus on risks.").
Build()
realist, _ := agent.New("realist", "Realist").
WithModel(model.NewOpenAI(apiKey)).
WithSystemPrompt("Analyze the topic objectively. Balance opportunities and risks.").
Build()
t := team.New("analysis", "Multi-Perspective Analysis", team.StrategyParallel).
AddAgent(optimist).
AddAgent(pessimist).
AddAgent(realist)
result, err := t.Run(ctx, graph.State{
"message": "What is the impact of AI on employment?",
})
if err != nil {
log.Fatal(err)
}
// Default merge combines all "response" values separated by "---"
fmt.Println(result["response"])
Controlling Concurrency
By default, all agents run simultaneously. Use SetMaxConcurrency to limit how many run at once — useful when you have many agents or limited API rate limits:
t := team.New("large-team", "Large Team", team.StrategyParallel).
AddAgent(agent1).
AddAgent(agent2).
AddAgent(agent3).
AddAgent(agent4).
AddAgent(agent5).
SetMaxConcurrency(2) // only 2 agents run at a time
Custom Merge Function
The default merge concatenates all responses. Use SetMerge for custom logic:
t.SetMerge(func(results []graph.State) graph.State {
merged := make(graph.State)
for i, r := range results {
// Namespace each agent's output to avoid key collisions
key := fmt.Sprintf("perspective_%d", i+1)
merged[key] = r["response"]
}
merged["total_perspectives"] = len(results)
return merged
})
Error Handling Strategies
Control what happens when an agent fails:
// FailFast (default): Stop everything on first error
t.SetErrorStrategy(team.ErrorStrategyFailFast)
// Collect: Gather all errors, return them together
t.SetErrorStrategy(team.ErrorStrategyCollect)
// BestEffort: Ignore failures, return whatever succeeded
t.SetErrorStrategy(team.ErrorStrategyBestEffort)
ErrorStrategyFailFast cancels all running agents the moment one fails. Use this when every agent’s result is critical.
ErrorStrategyCollect lets all agents finish, then returns a combined error listing every failure. Use this when you want a full picture of what went wrong.
ErrorStrategyBestEffort ignores failures and merges only the successful results. Use this when partial results are acceptable.
Strategy 3: Router (Intelligent Dispatch)
Router examines the input and sends it to exactly one agent — the best one for the job.
┌→ [Agent A] (if condition A)
Input ───┤→ [Agent B] (if condition B)
└→ [Agent C] (if condition C)
When to Use
- Customer support routing (billing vs. technical vs. sales)
- Task classification and dispatch
- Any scenario where different inputs need different specialists
Static Router (Function-Based)
The simplest router uses a function you write:
ctx := context.Background()
apiKey := os.Getenv("OPENAI_API_KEY")
billing, _ := agent.New("billing", "Billing Agent").
WithModel(model.NewOpenAI(apiKey)).
WithSystemPrompt("You handle billing and payment questions.").
AddCapability("billing").
Build()
technical, _ := agent.New("technical", "Technical Agent").
WithModel(model.NewOpenAI(apiKey)).
WithSystemPrompt("You handle technical issues and troubleshooting.").
AddCapability("technical").
Build()
sales, _ := agent.New("sales", "Sales Agent").
WithModel(model.NewOpenAI(apiKey)).
WithSystemPrompt("You handle pricing, plans, and purchasing questions.").
AddCapability("sales").
Build()
t := team.New("support", "Customer Support", team.StrategyRouter).
AddAgent(billing).
AddAgent(technical).
AddAgent(sales).
SetRouter(func(state graph.State) string {
msg, _ := state["message"].(string)
lower := strings.ToLower(msg)
switch {
case strings.Contains(lower, "invoice") || strings.Contains(lower, "payment"):
return "billing"
case strings.Contains(lower, "error") || strings.Contains(lower, "crash"):
return "technical"
case strings.Contains(lower, "pricing") || strings.Contains(lower, "upgrade"):
return "sales"
default:
return "technical" // default to technical support
}
})
// This will route to the billing agent
result, _ := t.Run(ctx, graph.State{"message": "I have a question about my invoice"})
fmt.Println(result["response"])
Model-Based Router (LLM-Powered)
For more nuanced routing, let an LLM decide which agent should handle the input:
router := model.NewOpenAI(os.Getenv("OPENAI_API_KEY"))
t := team.New("smart-support", "Smart Support", team.StrategyRouter).
AddAgent(billing).
AddAgent(technical).
AddAgent(sales).
SetModelRouter(func(ctx context.Context, state graph.State, agents []team.AgentInfo) (string, error) {
msg, _ := state["message"].(string)
// Build a prompt listing available agents
prompt := fmt.Sprintf("Given this customer message:\n\"%s\"\n\nWhich agent should handle it? ", msg)
prompt += "Available agents:\n"
for _, a := range agents {
prompt += fmt.Sprintf("- %s (ID: %s): %s\n", a.Name, a.ID, a.Description)
}
prompt += "\nRespond with ONLY the agent ID, nothing else."
resp, err := router.Chat(ctx, &model.ChatRequest{
Messages: []model.Message,
})
if err != nil {
return "", err
}
return strings.TrimSpace(resp.Content), nil
})
Capability-Based Routing (Automatic Fallback)
If you set neither SetRouter nor SetModelRouter, the router automatically scores agents based on how their advertised capabilities match the input state. This is a lightweight fallback that requires no configuration:
// These agents advertise their capabilities
billing, _ := agent.New("billing", "Billing").
AddCapability("billing").
AddCapability("payment").
Build()
technical, _ := agent.New("technical", "Technical").
AddCapability("debugging").
AddCapability("troubleshooting").
Build()
t := team.New("auto-router", "Auto Router", team.StrategyRouter).
AddAgent(billing).
AddAgent(technical)
// If the state contains "billing" as a key or value, the billing agent wins
result, _ := t.Run(ctx, graph.State{
"message": "Help me with billing",
"category": "billing",
})
Strategy 4: Coordinator (LLM-Driven Supervisor)
Coordinator uses a supervisor agent that analyzes the task, creates an execution plan, and delegates sub-tasks to specialist agents. The coordinator can re-plan based on intermediate results.
Input → [Coordinator] → Plan → [Agent A] ─┐
→ [Agent B] ──┤── Merge → (re-plan?) → Result
→ [Agent C] ─┘
When to Use
- Complex projects requiring task decomposition
- Dynamic workflows where the plan depends on intermediate results
- Any task where you’d assign a project manager to coordinate specialists
Basic Example
ctx := context.Background()
apiKey := os.Getenv("OPENAI_API_KEY")
// The supervisor agent — it creates the plan
supervisor, _ := agent.New("supervisor", "Project Manager").
Description("Decomposes complex tasks and coordinates specialists").
WithModel(model.NewOpenAI(apiKey)).
WithSystemPrompt("You are a project coordinator.").
Build()
// Specialist agents — they do the actual work
researcher, _ := agent.New("researcher", "Researcher").
Description("Researches topics and provides factual analysis").
WithModel(model.NewOpenAI(apiKey)).
WithSystemPrompt("You are a research specialist. Provide thorough analysis.").
Build()
writer, _ := agent.New("writer", "Writer").
Description("Writes polished articles and reports").
WithModel(model.NewOpenAI(apiKey)).
WithSystemPrompt("You are a writing specialist. Produce clear, engaging content.").
Build()
// Create the coordinator team
t := team.New("project", "Project Team", team.StrategyCoordinator).
SetCoordinator(supervisor).
AddAgent(researcher).
AddAgent(writer).
SetMaxIterations(2) // allow re-planning after first round
result, err := t.Run(ctx, graph.State{
"message": "Create a report on electric vehicle adoption trends in Europe",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(result["response"])
How the Coordinator Works
- Planning: The coordinator LLM receives the task and a list of available agents (with their IDs, names, descriptions, and capabilities). It produces a JSON plan:
{
"tasks": [
{"agent_id": "researcher", "description": "Research EV adoption data in Europe"},
{"agent_id": "writer", "description": "Write a report from the research", "depends_on": "researcher"}
],
"done": false
}
-
Execution: Tasks without
depends_onrun in parallel. Tasks withdepends_onwait for their dependency to finish first. Each task is delegated through the bus. -
Re-planning (optional): If
MaxIterations> 1, the coordinator sees the results and decides whether to issue more tasks or mark the work as done ("done": true).
Setting an Explicit Coordinator
Use SetCoordinator to designate the supervisor agent. This agent is not part of the worker pool — it only plans and delegates:
t := team.New("team", "Team", team.StrategyCoordinator).
SetCoordinator(supervisor). // supervisor plans, does not execute tasks
AddAgent(researcher). // worker
AddAgent(writer). // worker
AddAgent(reviewer) // worker
Without SetCoordinator (Backward Compatible)
If you don’t call SetCoordinator, the first agent added via AddAgent acts as both coordinator and worker:
t := team.New("team", "Team", team.StrategyCoordinator).
AddAgent(leadAgent). // first agent = coordinator
AddAgent(worker1).
AddAgent(worker2)
Controlling Iterations
SetMaxIterations controls how many planning cycles the coordinator can perform:
t.SetMaxIterations(3) // up to 3 rounds of plan → execute → re-plan
Set to 1 (the default) for a single-shot plan without re-planning.
Agent Communication
Agents in a team can communicate in three ways, listed from simplest to most flexible.
1. Shared State (Automatic)
When a team runs, state flows automatically between agents based on the strategy. You don’t need to write any communication code — it just works.
// Sequential: state flows from agent to agent
result, _ := t.Run(ctx, graph.State{"message": "Hello"})
// Every agent's output is merged into the result
2. Bus-Based Messaging (Structured)
The Protocol Bus routes typed messages between agents. It’s built into every team.
Delegate a task and wait for the result:
result, err := t.DelegateTask(ctx, "researcher", "writer", "draft-article",
protocol.TaskPayload{
Description: "Write a summary about solar energy",
Input: map[string]any{"message": "Write about solar energy"},
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Success: %v\n", result.Success)
fmt.Printf("Output: %v\n", result.Output["response"])
Ask a question and wait for an answer:
answer, err := t.Bus.Ask(ctx, "writer", "researcher", "What are the latest solar panel efficiency records?")
fmt.Println(answer)
Broadcast an update to all agents:
err := t.Broadcast(ctx, "researcher", "status-update", map[string]any{
"progress": 0.75,
"message": "Research phase 75% complete",
})
Find agents by capability:
reviewers := t.Bus.FindByCapability("review")
for _, peer := range reviewers {
fmt.Printf("Found reviewer: %s (%s)\n", peer.Name, peer.ID)
}
3. Direct Channels (Low-Latency Bypass)
For performance-critical paths, create a direct channel between two agents that bypasses the central bus entirely:
// Create a direct channel with buffer size 128
dc := t.DirectChannel("researcher", "writer", 128)
// Send from researcher to writer (non-blocking if buffer has space)
go func() {
body, _ := json.Marshal(map[string]string{
"findings": "Solar efficiency reached 47.6% in 2025",
})
dc.AtoB <- &protocol.Envelope{
Type: protocol.TypeTaskResult,
From: "researcher",
To: "writer",
Subject: "research_findings",
Body: body,
}
}()
// Writer receives directly
msg := <-dc.AtoB
fmt.Println(string(msg.Body))
// Send from writer back to researcher
dc.BtoA <- &protocol.Envelope{...}
Direct channels are bidirectional: AtoB sends from the first agent to the second, BtoA sends in the reverse direction.
Protocol Bus Reference
Message Types
| Type | Constant | Purpose |
|---|---|---|
| Task Request | protocol.TypeTaskRequest |
Ask an agent to perform work |
| Task Result | protocol.TypeTaskResult |
Return the outcome of delegated work |
| Question | protocol.TypeQuestion |
Ask an agent for information |
| Answer | protocol.TypeAnswer |
Respond to a question |
| Broadcast | protocol.TypeBroadcast |
Send an update to all agents |
| Handoff | protocol.TypeHandoff |
Transfer ownership of a task/conversation |
| Status | protocol.TypeStatus |
Report progress on long-running work |
| Ack | protocol.TypeAck |
Acknowledge receipt of a message |
| Error | protocol.TypeError |
Signal a failure |
Priority Levels
protocol.PriorityLow // 0
protocol.PriorityNormal // 1 (default)
protocol.PriorityHigh // 2
protocol.PriorityUrgent // 3
Bus Configuration
Tune the bus for your workload:
bus := protocol.NewBusWithConfig(protocol.BusConfig{
InboxSize: 1024, // per-agent inbox buffer (default: 512)
HistoryCap: 8192, // max retained history entries (default: 4096)
})
Envelope Pooling
For high-throughput scenarios, use the envelope pool to reduce garbage collection pressure:
env := protocol.AcquireEnvelope()
env.Type = protocol.TypeTaskRequest
env.From = "agent-a"
env.To = "agent-b"
env.Subject = "process-data"
env.Body, _ = json.Marshal(payload)
err := bus.Send(ctx, env)
protocol.ReleaseEnvelope(env) // return to pool when done
Message History (Observability)
Every message sent through the bus is recorded for debugging:
history := t.MessageHistory()
for _, msg := range history {
fmt.Printf("[%s] %s → %s: %s (type: %s)\n",
msg.CreatedAt.Format("15:04:05"),
msg.From, msg.To,
msg.Subject, msg.Type)
}
Complete Example: Content Creation Pipeline
This runnable example shows a realistic multi-agent content pipeline using sequential strategy, with proper error handling:
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/spawn08/chronos/engine/graph"
"github.com/spawn08/chronos/engine/model"
"github.com/spawn08/chronos/sdk/agent"
"github.com/spawn08/chronos/sdk/team"
)
func main() {
ctx := context.Background()
apiKey := os.Getenv("OPENAI_API_KEY")
if apiKey == "" {
log.Fatal("Set OPENAI_API_KEY environment variable")
}
provider := model.NewOpenAI(apiKey)
// Build specialist agents
researcher, err := agent.New("researcher", "Researcher").
Description("Gathers facts and data on a topic").
WithModel(provider).
WithSystemPrompt(`You are a research analyst.
When given a topic, provide 5 key facts with sources.
Format as a numbered list.`).
AddCapability("research").
Build()
if err != nil {
log.Fatalf("build researcher: %v", err)
}
writer, err := agent.New("writer", "Writer").
Description("Writes articles from research notes").
WithModel(provider).
WithSystemPrompt(`You are a professional writer.
Take the research provided and write a clear, engaging article.
Use headers and short paragraphs. Target 300-500 words.`).
AddCapability("writing").
Build()
if err != nil {
log.Fatalf("build writer: %v", err)
}
reviewer, err := agent.New("reviewer", "Reviewer").
Description("Reviews content for accuracy and quality").
WithModel(provider).
WithSystemPrompt(`You are a senior editor.
Review the article for factual accuracy, clarity, and grammar.
If the article is good, respond with the final version.
If changes are needed, make them and return the improved version.`).
AddCapability("review").
Build()
if err != nil {
log.Fatalf("build reviewer: %v", err)
}
// Assemble the team
t := team.New("content-pipeline", "Content Pipeline", team.StrategySequential).
AddAgent(researcher).
AddAgent(writer).
AddAgent(reviewer)
// Run the pipeline
result, err := t.Run(ctx, graph.State{
"message": "Write a short article about the future of space tourism",
})
if err != nil {
log.Fatalf("pipeline failed: %v", err)
}
fmt.Println("=== Final Article ===")
fmt.Println(result["response"])
fmt.Printf("\n=== Communication Log (%d messages) ===\n", len(t.MessageHistory()))
for _, msg := range t.MessageHistory() {
fmt.Printf(" %s → %s: %s\n", msg.From, msg.To, msg.Subject)
}
}
Complete Example: Smart Customer Support Router
This example routes customer messages to the right department using an LLM:
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"github.com/spawn08/chronos/engine/graph"
"github.com/spawn08/chronos/engine/model"
"github.com/spawn08/chronos/sdk/agent"
"github.com/spawn08/chronos/sdk/team"
)
func main() {
ctx := context.Background()
apiKey := os.Getenv("OPENAI_API_KEY")
if apiKey == "" {
log.Fatal("Set OPENAI_API_KEY environment variable")
}
provider := model.NewOpenAI(apiKey)
billing, _ := agent.New("billing", "Billing Support").
Description("Handles invoices, payments, refunds, and subscription changes").
WithModel(provider).
WithSystemPrompt("You are a billing support specialist. Help with payment and invoice questions.").
AddCapability("billing").AddCapability("payments").
Build()
technical, _ := agent.New("technical", "Technical Support").
Description("Handles bugs, errors, crashes, and technical troubleshooting").
WithModel(provider).
WithSystemPrompt("You are a technical support engineer. Help diagnose and fix issues.").
AddCapability("debugging").AddCapability("troubleshooting").
Build()
sales, _ := agent.New("sales", "Sales").
Description("Handles pricing questions, plan upgrades, and new purchases").
WithModel(provider).
WithSystemPrompt("You are a sales representative. Help with pricing and purchasing decisions.").
AddCapability("pricing").AddCapability("sales").
Build()
// Model-based routing — the LLM picks the best agent
routerModel := model.NewOpenAI(apiKey)
t := team.New("support", "Customer Support", team.StrategyRouter).
AddAgent(billing).
AddAgent(technical).
AddAgent(sales).
SetModelRouter(func(ctx context.Context, state graph.State, agents []team.AgentInfo) (string, error) {
msg, _ := state["message"].(string)
prompt := fmt.Sprintf(
"Customer message: \"%s\"\n\nAvailable agents:\n", msg)
for _, a := range agents {
prompt += fmt.Sprintf("- ID=%s: %s — %s\n", a.ID, a.Name, a.Description)
}
prompt += "\nRespond with ONLY the agent ID that should handle this message."
resp, err := routerModel.Chat(ctx, &model.ChatRequest{
Messages: []model.Message,
})
if err != nil {
return "", err
}
return strings.TrimSpace(resp.Content), nil
})
// Test with different customer messages
messages := []string{
"My invoice shows a double charge for last month",
"The app crashes when I try to upload a file larger than 10MB",
"What's the price difference between Pro and Enterprise plans?",
}
for _, msg := range messages {
result, err := t.Run(ctx, graph.State{"message": msg})
if err != nil {
log.Printf("Error: %v", err)
continue
}
fmt.Printf("Customer: %s\n", msg)
fmt.Printf("Response: %s\n\n", result["response"])
}
}
Team Builder Reference
Constructor
team.New(id string, name string, strategy team.Strategy) *Team
Configuration Methods
All methods return *Team for chaining.
| Method | Description | Strategies |
|---|---|---|
AddAgent(a *agent.Agent) |
Add an agent to the team | All |
SetRouter(fn RouterFunc) |
Set a static routing function | Router |
SetModelRouter(fn ModelRouterFunc) |
Set an LLM-based routing function | Router |
SetMerge(fn MergeFunc) |
Set a custom result merge function | Parallel |
SetMaxConcurrency(n int) |
Limit concurrent goroutines | Parallel |
SetErrorStrategy(es ErrorStrategy) |
Control failure behavior | Parallel |
SetCoordinator(a *agent.Agent) |
Set the supervisor agent | Coordinator |
SetMaxIterations(n int) |
Max planning iterations | Coordinator |
Execution
result, err := t.Run(ctx, graph.State{"message": "your task"})
Returns a graph.State (which is map[string]any) containing the combined output from all agents that ran.
Communication
| Method | Description |
|---|---|
DelegateTask(ctx, from, to, subject, payload) |
Send a task and wait for the result |
Broadcast(ctx, from, subject, data) |
Send a message to all agents |
DirectChannel(agentA, agentB, bufSize) |
Create a direct channel between two agents |
MessageHistory() |
Get all messages exchanged during the run |
Bus |
Access the underlying Protocol Bus directly |
Type Signatures
type RouterFunc func(state graph.State) string
type ModelRouterFunc func(ctx context.Context, state graph.State, agents []AgentInfo) (string, error)
type MergeFunc func(results []graph.State) graph.State
type AgentInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Capabilities []string `json:"capabilities"`
}
Running the Demo
Chronos includes a complete demo that exercises all four strategies, direct channels, and bus delegation:
# With a real provider
OPENAI_API_KEY=sk-... go run ./examples/multi_agent/
# Without an API key (uses a mock provider for demonstration)
go run ./examples/multi_agent/
The demo shows:
- Sequential pipeline (Researcher → Writer → Reviewer)
- Parallel fan-out with bounded concurrency
- Router with static dispatch
- Coordinator with LLM-driven planning
- Direct agent-to-agent channel
- Bus-based task delegation
Tips and Best Practices
Start with lightweight agents. Use model-only agents (agent.New(...).WithModel(...).Build()) for team orchestration. Only add graphs and storage when you need durable workflows.
Give agents clear descriptions and capabilities. These are used by the coordinator for planning and by the router for automatic dispatch.
Use system prompts to define boundaries. Tell each agent what it should and shouldn’t do. Be specific: “You are a researcher. Provide facts, not opinions.”
Choose the right error strategy for parallel teams. Use FailFast for critical pipelines, BestEffort when partial results are acceptable.
Use SetMaxConcurrency with parallel teams. If you have many agents or limited API rate limits, bound the concurrency to avoid hitting rate limits.
Use SetMaxIterations wisely with coordinator. Each iteration costs a model call for re-planning. Start with 1 and increase only if you need iterative refinement.
Use direct channels for hot paths. When two specific agents exchange many messages, a direct channel avoids the overhead of bus routing.