Skip to content

Lesson 7: Module 07: Composition and Multi-Agent Patterns

Build systems from specialist machines, each focused on one job.

Learning Objectives

  • Use :call steps to invoke other machines
  • Design specialist machines with narrow responsibilities
  • Build a coordinator that orchestrates multiple specialists
  • Understand when composition beats monolithic agents

Complexity Ladder: Level 5 (Agentic) — composing multiple specialist agents into coordinated systems.

The Concept: Specialists Over Generalists

Think of it like a hospital. Instead of one doctor doing everything — diagnosis, surgery, prescriptions, physical therapy — you have specialists. A coordinator (the primary care physician) routes patients to the right specialist. Each specialist is excellent at their narrow focus, and the system is more reliable than one person trying to do everything.

A single agent with many tools becomes unreliable. Research shows agents with >5 tools drop to 40-50% accuracy, while single-tool specialists (machines focused on one capability) maintain 95%+. The solution: decompose complex tasks into specialist machines, each focused on one capability, orchestrated by a coordinator (a machine that delegates work to specialist machines).

This is the same principle behind Mashin’s architecture: code computes, machines effect. Each machine is a governed unit with clear inputs and outputs. Composition (building machines from machines) gives you complex behavior from simple, testable parts.

┌─────────────────────────┐
│ COORDINATOR │
│ Plans, routes, reviews │
└────┬────────┬────────┬──┘
│ │ │
┌────────▼──┐ ┌──▼──────┐ ┌▼──────────┐
│ SEARCHER │ │ WRITER │ │ REVIEWER │
│ │ │ │ │ │
│ Web search │ │Synthesis│ │ Quality │
│ + compile │ │+ format │ │ check │
│ │ │ │ │ │
│ Has: web │ │ Has: AI │ │ Has: AI │
│ tools │ │ only │ │ only │
└────────────┘ └─────────┘ └───────────┘

The coordinator handles planning and routing. Each specialist does one thing well. Data flows through explicit input/output contracts (the agreed-upon data shape between machines) — no shared state, no surprises.

Start With Koda

Koda requires a free account. Sign in or create an account to use Koda exercises throughout this course. If you’re not signed in yet, read on; the exercises will be here when you’re ready.

Before diving into the syntax details, try building a multi-agent system with Koda:

Ask Koda:

“Build two machines: a summarizer that condenses text into key points, and a fact-checker that verifies claims against a knowledge base. Then build a coordinator that summarizes an article and fact-checks the summary.”

Verify that Koda creates three separate machines with clear inputs/outputs, and the coordinator uses :call steps to invoke them in sequence. Then continue reading to understand the patterns Koda used.

The :call Step

The :call step invokes another machine, passing inputs and receiving outputs:

ask summarize, from: "@myorg/text/summarizer" // Invoke the summarizer specialist
text: step(fetch, :body) // Pass the fetched body text as input
max_length: 500 // Limit summary to 500 words

The called machine runs in its own context — with full isolation (each machine runs independently with its own state). Its outputs become the step’s outputs, accessible via step(:summarize, :field).

Chain Selection

You can call a specific flow within a machine:

ask quick_check, from: "@myorg/validator"
flow: quick_validate // Call a specific flow, not main
data: input.payload // Pass the payload for validation

Expert Roles

Complex tasks decompose naturally into expert roles:

ExpertResponsibilityMachine Pattern
PlannerTask decomposition, strategyask step that outputs a plan
ExecutorAction implementation:call to effect machines (machines that wrap external I/O)
ReflectorQuality evaluationask step that evaluates output
Error HandlerFailure diagnosis/recoveryon_error: flow
Memory ManagerContext retrieval:remember steps

Each role becomes its own machine. The coordinator calls them in the right order.

Building It: Research Coordinator

Let’s build a system where a coordinator (an orchestrator that delegates work to specialist machines) delegates research to a searcher and synthesis to a writer.

Machine 1: Searcher

machine searcher "Web Searcher"
accepts
topic as string, is required // The research topic to search for
num_queries as integer, default: 3 // How many search queries to generate
responds with
findings as list // List of search results as maps
sources as list // List of source URLs found
implements
// Step 1: Use AI to generate diverse search queries
ask plan_searches, using: "anthropic:claude-haiku-4"
temperature: 0.3
with task """
Generate ${input.num_queries} diverse search queries to research: ${input.topic}
Each query should approach the topic from a different angle.
"""
returns
queries as list, is required
// Step 2: Execute each search query using for_each
for_each step(plan_searches, :queries)
ask search, from: "@mashin/actions/tools/web_search"
query: loop.item // loop.item is the current query string
// Step 3: Compile and deduplicate all search results
compute compile_findings
"""
results = step(:search)
findings = results
|> Enum.flat_map(fn r -> r["results"] || [] end)
|> Enum.uniq_by(fn r -> r["url"] end)
sources = Enum.map(findings, fn r -> r["url"] end)
%{findings: findings, sources: sources}
"""

Machine 2: Writer

machine writer "Research Writer"
accepts
topic as string, is required
findings as list, is required // Raw research data to synthesize
format as string, default: "summary", choices: ["summary", "report", "bullets"]
responds with
content as string
word_count as integer
implements
// Step 1: Use a capable model to synthesize findings into prose
ask synthesize, using: "anthropic:claude-sonnet-4"
with role """
You are a research writer. Synthesize findings into clear, well-structured
content. Always cite sources. Never add information not present in the findings.
"""
with task """
Write a ${input.format} about: ${input.topic}
Research findings:
${input.findings}
"""
returns
content as string, is required
// Step 2: Count words in the output for metadata
compute measure
"""
content = step(:synthesize, :content)
words = content |> String.split() |> length()
%{content: content, word_count: words}
"""

Machine 3: Coordinator

machine coordinator "Research Coordinator"
accepts
question as string, is required
depth as string, default: "standard", choices: ["quick", "standard", "deep"]
responds with
answer as string
sources as list
word_count as integer
implements
// Step 1: Plan the research approach
ask plan, using: "anthropic:claude-haiku-4"
temperature: 0.2
with task """
Break this research question into a search topic and output format.
Depth: ${input.depth}
Question: ${input.question}
"""
returns
topic as string, is required
num_queries as integer, is required
format as string, choices: ["summary", "report", "bullets"]
// Step 2: Delegate search to the searcher specialist
ask research, from: "@myorg/research/searcher"
topic: step(plan, :topic)
num_queries: step(plan, :num_queries)
// Step 3: Delegate synthesis to the writer specialist
ask write, from: "@myorg/research/writer"
topic: step(plan, :topic)
findings: step(research, :findings)
format: step(plan, :format)
// Step 4: Quality check
ask review, using: "anthropic:claude-haiku-4"
temperature: 0.2
with task """
Does this answer address the original question?
Question: ${input.question}
Answer: ${steps.write.content}
Respond with pass/fail and a brief reason.
"""
returns
passes as boolean
reason as string
// Step 5: Format final output
compute format_output
"""
%{
answer: step(:write, :content),
sources: step(:research, :sources),
word_count: step(:write, :word_count)
}
"""

Why Composition Works

Isolation: Each machine has its own state, error handling, and governance. A bug in the searcher doesn’t corrupt the writer. Isolation (each machine runs independently with its own state) is a core property of the composition model.

Reusability: The searcher and writer can be used independently or by other coordinators. Build once, compose many ways.

Testability: Test each specialist in isolation with known inputs. The coordinator test verifies orchestration logic, not search or synthesis quality.

Governance: Each machine declares its own permissions. The searcher needs web access; the writer needs none. Governance is granular.

Reliability: Single-purpose machines are more reliable than multi-tool agents. The coordinator (simple routing) stays reliable because it delegates complexity.

Effect Machines

This is the Mashin principle in action: code computes, machines effect. Effect machines handle all I/O — governed, tracked, and permission-controlled.

When you need to wrap an external API or service, create an effect machine (a machine that wraps external I/O) that composes stdlib:

machine slack_notify "Slack Notifier"
accepts
channel as string, is required // Slack channel to post to
message as string, is required // Message text to send
responds with
sent as boolean
timestamp as string
implements
// Delegate the actual HTTP call to the stdlib HTTP effect machine
ask send, from: "@mashin/actions/http/post"
url: "https://slack.com/api/chat.postMessage"
headers: %{
"Authorization" => "Bearer ${environment.SLACK_TOKEN}",
"Content-Type" => "application/json"
}
body: %{
channel: input.channel,
text: input.message
}

This effect machine wraps the Slack API behind a clean input/output contract (the agreed-upon data shape between machines). Other machines call it without knowing the HTTP details.

Key Syntax

# Call another machine (composition — building machines from machines)
step :name, type: :call do
call_machine "@namespace/machine_name" do
input :field, value: expression # Pass data to the called machine
flow :specific_chain # Optional: call specific flow
end
end
# Access called machine's output
step(:name, :output_field)
# Effect machine declaration (wraps external I/O behind a governed interface)
machine "@myorg/effects/name" do
@type :effect
# ... governed I/O wrapping stdlib
end

Common Mistakes

  1. Making the coordinator too smart. The coordinator (orchestrator) should route and wire, not reason deeply. Keep planning in a separate ask step, and keep complex logic in specialist machines.

  2. Sharing state between machines. Machines don’t share state. Pass data explicitly through inputs and outputs. If you need shared context, use memory (type: :remember).

  3. Creating too many tiny machines. Composition is powerful, but don’t split a 3-step workflow into 3 machines. Compose when you have genuinely independent responsibilities, not just separate steps.

What’s Next

In Module 08, you’ll learn how to take your agents to production — governance, error handling, evaluation, and cost control.