Lesson 7: Module 07: Composition and Multi-Agent Patterns
Build systems from specialist machines, each focused on one job.
Learning Objectives
- Use
:callsteps 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 wordsThe 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 validationExpert Roles
Complex tasks decompose naturally into expert roles:
| Expert | Responsibility | Machine Pattern |
|---|---|---|
| Planner | Task decomposition, strategy | ask step that outputs a plan |
| Executor | Action implementation | :call to effect machines (machines that wrap external I/O) |
| Reflector | Quality evaluation | ask step that evaluates output |
| Error Handler | Failure diagnosis/recovery | on_error: flow |
| Memory Manager | Context 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 endend
# Access called machine's outputstep(: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 stdlibendCommon Mistakes
-
Making the coordinator too smart. The coordinator (orchestrator) should route and wire, not reason deeply. Keep planning in a separate
askstep, and keep complex logic in specialist machines. -
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). -
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.