Prerequisites
Complete Memory and retrieval-augmented agents before starting. You should be comfortable with Jido.AI.Agent, reasoning Strategies, tool-calling Actions, Signal routing, hierarchical agent spawning, and the Plugin system.
Setup
Mix.install([
{:jido, "~> 2.0"},
{:jido_ai, github: "agentjido/jido_ai", branch: "main"},
{:req_llm, "~> 1.6"}
])
A polling helper waits for asynchronous results across the agent hierarchy.
defmodule Helpers do
def wait_for(fun, timeout \\ 30_000, interval \\ 500) do
deadline = System.monotonic_time(:millisecond) + timeout
do_wait(fun, deadline, interval)
end
defp do_wait(fun, deadline, interval) do
if System.monotonic_time(:millisecond) > deadline do
raise "Timed out waiting for condition"
end
case fun.() do
{:ok, result} -> result
:retry -> Process.sleep(interval); do_wait(fun, deadline, interval)
end
end
end
Configure credentials
Set your OpenAI API key. In Livebook, add OPENAI_API_KEY as a Livebook Secret prefixed with LB_.
openai_key = System.get_env("LB_OPENAI_API_KEY") || System.get_env("OPENAI_API_KEY")
if openai_key do
ReqLLM.put_key(:openai_api_key, openai_key)
:configured
else
raise "Set OPENAI_API_KEY as a Livebook Secret or environment variable."
end
When one agent isn’t enough
A single agent with one Strategy can answer questions, call tools, and maintain conversation state. But complex goals like “Research and write a technical comparison of Elixir vs Go for distributed systems” require different capabilities applied in sequence: structured decomposition, data gathering, and synthesis. Instead of one oversized agent, compose three specialists that each do one thing well under a coordinator.
By the end of this tutorial, you will have a coordinator agent that:
- Decomposes a complex goal into ordered sub-tasks using the Planning Plugin
- Routes each sub-task to the right specialist via Signals
- Aggregates specialist outputs into a final result
The architecture looks like this:
Coordinator
|
|-- planning.decompose --> sub-tasks
|
|-- spawns --> PlannerAgent (CoT Strategy)
|-- spawns --> ResearcherAgent (ReAct Strategy)
|-- spawns --> WriterAgent (synthesis)
|
|<-- specialist.result -- each specialist
|
|--> assembled final output
The Skills system
Jido.AI.Skill.Spec is a declarative description of what an agent can do. It bundles metadata with the Actions and Plugins that implement a capability.
alias Jido.AI.Skill.Spec
research_skill = %Spec{
name: "technical-research",
description: "Gather and analyze technical information",
actions: [MyApp.SearchAction, MyApp.SummarizeAction],
plugins: [Jido.AI.Plugins.Chat],
tags: ["research", "analysis"],
vsn: "1.0.0"
}
Key fields on Spec:
-
nameanddescriptionidentify the Skill for discovery and prompt injection -
actionslists theJido.Actionmodules the Skill provides -
pluginslists Plugins the Skill requires on its host Agent -
allowed_toolsconstrains which tools the Skill can invoke -
body_refpoints to a SKILL.md file with extended instructions
At runtime, Jido.AI.Skill.Loader parses SKILL.md files (YAML frontmatter plus body instructions) into Spec structs. Jido.AI.Skill.Registry is an ETS-backed GenServer for fast lookup by name.
alias Jido.AI.Skill.{Loader, Registry}
# Load a file-based Skill
{:ok, spec} = Loader.load("priv/skills/research/SKILL.md")
# Register and discover at runtime
Registry.start_link()
Registry.register(spec)
{:ok, found} = Registry.lookup("technical-research")
Skills compose into agent configurations. You reference module-based Skills directly and file-based Skills by name after registration.
Planning plugin
The Planning Plugin provides three LLM-powered Actions for breaking down complex goals. Add it to an Agent to gain access to structured decomposition through Signal routing.
# Plugin signal routes:
# "planning.plan" -> Jido.AI.Actions.Planning.Plan
# "planning.decompose" -> Jido.AI.Actions.Planning.Decompose
# "planning.prioritize" -> Jido.AI.Actions.Planning.Prioritize
The Decompose Action is the one this tutorial relies on most. It takes a goal string and returns a hierarchical breakdown with sub-goals, dependencies, and success criteria.
{:ok, result} = Jido.Exec.run(
Jido.AI.Actions.Planning.Decompose,
%{goal: "Compare Elixir and Go for distributed systems", max_depth: 2}
)
IO.puts(result.decomposition)
IO.inspect(result.sub_goals, label: "Sub-goals")
The result contains decomposition (full text), sub_goals (extracted list), goal, depth, and model. Each sub-goal becomes a work item for a specialist agent.
Prioritize orders those sub-goals so the coordinator dispatches them in dependency order:
{:ok, priority} = Jido.Exec.run(
Jido.AI.Actions.Planning.Prioritize,
%{
tasks: result.sub_goals,
criteria: "Dependencies and logical sequencing",
context: "Technical comparison article"
}
)
IO.inspect(priority.ordered_tasks, label: "Execution order")
Define specialist agents
Each specialist uses Jido.AI.Agent with a different Strategy and system prompt. The specialists handle a specialist.work Signal, perform their task with the LLM, and emit a specialist.result Signal back to the coordinator.
Planner agent. Uses Chain of Thought for structured, step-by-step decomposition.
defmodule MyApp.HandleWorkAction do
use Jido.Action,
name: "handle_specialist_work",
schema: [
task: [type: :string, required: true],
task_id: [type: :string, required: true],
role: [type: :string, required: true]
]
alias Jido.Agent.Directive
def run(params, context) do
result_signal =
Jido.Signal.new!(
"specialist.result",
%{task_id: params.task_id, role: params.role, output: "processed"},
source: "/specialist/#{params.role}"
)
agent_like = %{state: context.state}
emit = Directive.emit_to_parent(agent_like, result_signal)
{:ok, %{last_task: params.task_id}, List.wrap(emit)}
end
end
This Action is shared by all three specialists. In a production system, each specialist would call ask_sync/3 with a role-specific prompt and return the LLM output. The shared Action keeps this tutorial focused on the orchestration pattern.
defmodule MyApp.PlannerAgent do
use Jido.AI.Agent,
name: "planner_agent",
description: "Structured decomposition specialist",
model: "openai:gpt-4o-mini",
max_iterations: 3,
system_prompt: """
You are a planning specialist. Break goals into
concrete, ordered steps with clear dependencies.
"""
def signal_routes(_ctx) do
[{"specialist.work", MyApp.HandleWorkAction}]
end
end
Researcher agent. Uses ReAct for iterative reasoning with tool calls.
defmodule MyApp.ResearcherAgent do
use Jido.AI.Agent,
name: "researcher_agent",
description: "Data gathering and analysis specialist",
model: "openai:gpt-4o-mini",
max_iterations: 6,
system_prompt: """
You are a research specialist. Gather relevant data,
compare sources, and produce factual summaries.
"""
def signal_routes(_ctx) do
[{"specialist.work", MyApp.HandleWorkAction}]
end
end
Writer agent. Synthesizes inputs into polished output.
defmodule MyApp.WriterAgent do
use Jido.AI.Agent,
name: "writer_agent",
description: "Content synthesis specialist",
model: "openai:gpt-4o-mini",
max_iterations: 3,
system_prompt: """
You are a writing specialist. Synthesize research
into clear, well-structured technical content.
"""
def signal_routes(_ctx) do
[{"specialist.work", MyApp.HandleWorkAction}]
end
end
Each specialist routes specialist.work to the shared Action. The role field in the Signal payload identifies which specialist processed the task, so the coordinator can track provenance.
Build the coordinator
The coordinator combines the Planning Plugin with hierarchical agent spawning. It receives a goal, decomposes it, spawns specialists, and routes sub-tasks as Signals.
Decompose the goal and spawn specialists.
defmodule MyApp.DecomposeGoalAction do
use Jido.Action,
name: "decompose_goal",
schema: [
goal: [type: :string, required: true]
]
alias Jido.Agent.Directive
def run(%{goal: goal}, _context) do
tasks = [
%{id: "plan", role: "planner", task: "Create structure for: #{goal}"},
%{id: "research", role: "researcher", task: "Gather data for: #{goal}"},
%{id: "write", role: "writer", task: "Draft content for: #{goal}"}
]
spawns = [
Directive.spawn_agent(MyApp.PlannerAgent, :planner,
meta: %{task_id: "plan", role: "planner", task: Enum.at(tasks, 0).task}),
Directive.spawn_agent(MyApp.ResearcherAgent, :researcher,
meta: %{task_id: "research", role: "researcher", task: Enum.at(tasks, 1).task}),
Directive.spawn_agent(MyApp.WriterAgent, :writer,
meta: %{task_id: "write", role: "writer", task: Enum.at(tasks, 2).task})
]
{:ok, %{goal: goal, pending: 3, results: %{}}, spawns}
end
end
In a production coordinator, you would send a planning.decompose Signal to yourself (handled by the Planning Plugin) and use the LLM-generated sub-goals to decide which specialists to spawn. This simplified version hard-codes three tasks to keep the focus on signal flow.
Dispatch work when children start.
defmodule MyApp.CoordinatorChildStartedAction do
use Jido.Action,
name: "coordinator_child_started",
schema: [
parent_id: [type: :string, required: true],
child_id: [type: :string, required: true],
child_module: [type: :any, required: true],
tag: [type: :any, required: true],
pid: [type: :any, required: true],
meta: [type: :map, default: %{}]
]
alias Jido.Agent.Directive
def run(%{pid: pid, meta: meta}, _context) do
work_signal =
Jido.Signal.new!(
"specialist.work",
%{task_id: meta.task_id, role: meta.role, task: meta.task},
source: "/coordinator"
)
{:ok, %{}, [Directive.emit_to_pid(work_signal, pid)]}
end
end
This follows the same pattern from the Parent-child agent hierarchies tutorial. The meta map on SpawnAgent carries task details, and the jido.agent.child.started Signal delivers them back with the child’s PID.
Aggregate specialist results.
defmodule MyApp.HandleSpecialistResultAction do
use Jido.Action,
name: "handle_specialist_result",
schema: [
task_id: [type: :string, required: true],
role: [type: :string, required: true],
output: [type: :any, required: true]
]
def run(params, context) do
results = Map.get(context.state, :results, %{})
updated_results = Map.put(results, params.role, params.output)
pending = Map.get(context.state, :pending, 3) - 1
state = %{results: updated_results, pending: pending}
state =
if pending <= 0 do
Map.put(state, :status, :complete)
else
state
end
{:ok, state}
end
end
When pending reaches zero, the coordinator marks itself :complete. All specialist outputs are collected in the results map keyed by role.
Wire the coordinator Agent.
defmodule MyApp.CoordinatorAgent do
use Jido.Agent,
name: "coordinator_agent",
plugins: [{Jido.AI.Plugins.Planning, []}],
schema: [
goal: [type: :string, default: nil],
pending: [type: :integer, default: 0],
results: [type: :map, default: %{}],
status: [type: :atom, default: :idle]
]
def signal_routes(_ctx) do
[
{"orchestrate", MyApp.DecomposeGoalAction},
{"jido.agent.child.started", MyApp.CoordinatorChildStartedAction},
{"specialist.result", MyApp.HandleSpecialistResultAction}
]
end
end
The coordinator is a plain Jido.Agent (not Jido.AI.Agent) with the Planning Plugin attached. This gives it access to planning.decompose, planning.plan, and planning.prioritize Signal routes from the Plugin alongside its own routes.
Signal flow across AI agents
The full signal flow for a single orchestration run:
1. External code sends "orchestrate" signal to coordinator
2. DecomposeGoalAction emits SpawnAgent directives (3 children)
3. Runtime starts PlannerAgent, ResearcherAgent, WriterAgent
4. Each child triggers "jido.agent.child.started" on coordinator
5. CoordinatorChildStartedAction emits "specialist.work" to child PID
6. Child routes "specialist.work" to HandleWorkAction
7. HandleWorkAction emits "specialist.result" to parent (coordinator)
8. HandleSpecialistResultAction aggregates, decrements pending
9. When pending hits 0, status becomes :complete
Signals flow downward as work assignments and upward as results. Each layer only communicates with its direct parent or children. The coordinator never reaches into a specialist’s internals, and specialists never coordinate with each other directly.
End-to-end orchestration
Start the Jido runtime and launch the coordinator.
{:ok, jido} = Jido.start_link(name: :learn_orchestration)
{:ok, coordinator_pid} =
Jido.start_agent(jido, MyApp.CoordinatorAgent, id: "coordinator-1")
Send the orchestration Signal with a complex goal.
signal =
Jido.Signal.new!(
"orchestrate",
%{goal: "Compare Elixir and Go for building distributed systems"},
source: "/livebook"
)
{:ok, _agent} = Jido.AgentServer.call(coordinator_pid, signal)
The call returns after the coordinator processes the orchestrate Signal and emits the three SpawnAgent Directives. The specialists start and execute asynchronously. Poll for the completed result.
result =
Helpers.wait_for(fn ->
case Jido.AgentServer.state(coordinator_pid) do
{:ok, %{agent: %{state: %{status: :complete} = state}}} ->
{:ok, state}
_ ->
:retry
end
end)
IO.inspect(result.goal, label: "Goal")
IO.inspect(Map.keys(result.results), label: "Specialists completed")
IO.inspect(result.pending, label: "Pending")
You should see output showing the goal, three completed specialists (["planner", "researcher", "writer"]), and a pending count of 0.
Inspect the hierarchy to confirm the coordinator’s children.
{:ok, coord_state} = Jido.AgentServer.state(coordinator_pid)
IO.inspect(
Map.keys(coord_state.children),
label: "Coordinator children"
)
Each specialist appears as a child process managed by the runtime. The coordinator tracks them through the standard children map, the same mechanism used in non-AI hierarchies.
Next steps
This tutorial demonstrated the orchestration pattern: decompose, delegate, and aggregate across specialized AI agents. The simplified specialists show the wiring; production implementations would add full LLM calls, error recovery, and richer skill configurations.