Prerequisites
Complete Build your first workflow before starting this tutorial. You need a working understanding of cmd/2, action chaining, and how context.state flows between actions.
Setup
Mix.install([
{:jido, "~> 2.0"}
])
Why plugins
Agents that share capabilities end up duplicating action lists, state fields, and signal routes across modules. A Plugin packages all of that into a single reusable module: its own Actions, state slice, and signal routing table. You declare the Plugin on any Agent and the Agent gains those capabilities at compile time.
Here is the end result. Two Agents with different configurations, both gaining note-taking through one Plugin:
# Preview — you will build these modules step by step below
defmodule MyApp.NotesAgent do
use Jido.Agent,
name: "notes_agent",
plugins: [MyApp.NotesPlugin]
end
defmodule MyApp.WorkNotesAgent do
use Jido.Agent,
name: "work_notes_agent",
plugins: [{MyApp.NotesPlugin, %{label: "work"}}]
end
Define the Actions
Each Plugin bundles Actions that operate on the Plugin’s state slice. The Plugin declares a state_key (here :notes), and Actions read that slice from context.state.
AddNoteAction appends a timestamped note to the :entries list stored under the Plugin’s state key:
defmodule MyApp.AddNoteAction do
use Jido.Action,
name: "add_note",
schema: Zoi.object(%{
text: Zoi.string()
})
@impl true
def run(params, context) do
notes = get_in(context.state, [:notes, :entries]) || []
note = %{text: params.text, added_at: DateTime.utc_now()}
{:ok, %{notes: %{entries: [note | notes]}}}
end
end
The return map uses the :notes key, which matches the Plugin’s state_key. The runtime deep-merges this into the Agent’s state.
ClearNotesAction resets the entries list:
defmodule MyApp.ClearNotesAction do
use Jido.Action,
name: "clear_notes",
schema: Zoi.object(%{})
@impl true
def run(_params, _context) do
{:ok, %{notes: %{entries: []}}}
end
end
Build the Plugin
A Plugin module declares the Actions it provides, the state key it owns, a Zoi schema for its state slice, and the signal patterns it handles.
defmodule MyApp.NotesPlugin do
use Jido.Plugin,
name: "notes_plugin",
state_key: :notes,
actions: [MyApp.AddNoteAction, MyApp.ClearNotesAction],
description: "Manages a list of notes",
schema: Zoi.object(%{
entries: Zoi.list(Zoi.any()) |> Zoi.default([])
}),
signal_patterns: ["notes.*"]
@impl Jido.Plugin
def mount(_agent, config) do
label = Map.get(config, :label, "default")
{:ok, %{label: label}}
end
@impl Jido.Plugin
def signal_routes(_config) do
[
{"notes.add", MyApp.AddNoteAction},
{"notes.clear", MyApp.ClearNotesAction}
]
end
end
Three things to note:
-
state_key: :notesisolates this Plugin’s state underagent.state.notes. Other Plugins cannot collide with it. -
mount/2runs when you callAgent.new/1. It receives the Agent struct and any per-Agent config, returning initial state that merges into the Plugin’s slice. -
signal_routes/1maps signal type strings to Actions. When a"notes.add"Signal arrives, the router dispatches it toMyApp.AddNoteAction.
Wire it to an Agent
Declare the Plugin in the Agent’s plugins list. No other configuration is needed for the default case.
defmodule MyApp.NotesAgent do
use Jido.Agent,
name: "notes_agent",
plugins: [MyApp.NotesPlugin]
end
Create an Agent struct and inspect its initial state. The Plugin’s mount/2 callback has already run, setting up the :notes slice with default values:
agent = MyApp.NotesAgent.new()
IO.inspect(agent.state.notes, label: "Initial notes state")
You should see entries: [] and label: "default" in the notes state.
Plugin configuration
To customize a Plugin per Agent, pass a {Module, config_map} tuple. The config map flows through mount/2 as the second argument.
defmodule MyApp.WorkNotesAgent do
use Jido.Agent,
name: "work_notes_agent",
plugins: [{MyApp.NotesPlugin, %{label: "work"}}]
end
work_agent = MyApp.WorkNotesAgent.new()
IO.inspect(work_agent.state.notes, label: "Work notes state")
The label is now "work" instead of "default". Both Agents share the same Plugin module but carry independent state and configuration.
Use the Plugin
Direct execution with cmd/2
You can call Plugin Actions directly through cmd/2, the same way you call any Action. Add two notes and inspect the accumulated state:
agent = MyApp.NotesAgent.new()
{agent, _directives} =
MyApp.NotesAgent.cmd(agent, [
{MyApp.AddNoteAction, %{text: "Buy groceries"}},
{MyApp.AddNoteAction, %{text: "Review PR #42"}}
])
IO.inspect(agent.state.notes.entries, label: "After adding")
The entries list contains both notes in reverse insertion order (most recent first). Now clear them:
{agent, _directives} =
MyApp.NotesAgent.cmd(agent, MyApp.ClearNotesAction)
IO.inspect(agent.state.notes.entries, label: "After clearing")
The entries list is empty again.
Signal routing through the runtime
Plugins define signal routes so you can drive Actions through Signals instead of direct cmd/2 calls. Start the Jido runtime, spawn an Agent process, and send Signals:
{:ok, jido} = Jido.start_link(name: :learn_plugins)
{:ok, pid} =
Jido.start_agent(jido, MyApp.NotesAgent, id: "notes-demo")
Send a "notes.add" Signal. The router matches it to MyApp.AddNoteAction through the Plugin’s signal_routes/1:
signal =
Jido.Signal.new!(
"notes.add",
%{text: "Signal-routed note"},
source: "/tutorial"
)
{:ok, agent} = Jido.AgentServer.call(pid, signal)
IO.inspect(agent.state.notes.entries, label: "After signal")
Add a second note and then clear all notes through Signals:
add_signal =
Jido.Signal.new!(
"notes.add",
%{text: "Another note via signal"},
source: "/tutorial"
)
{:ok, agent} = Jido.AgentServer.call(pid, add_signal)
IO.inspect(
length(agent.state.notes.entries),
label: "Note count"
)
clear_signal =
Jido.Signal.new!(
"notes.clear",
%{},
source: "/tutorial"
)
{:ok, agent} = Jido.AgentServer.call(pid, clear_signal)
IO.inspect(agent.state.notes.entries, label: "After clear")
Both paths (cmd/2 and Signal routing) produce identical state transitions. Use cmd/2 when you have a direct reference to the Agent struct. Use Signals when the Agent runs as a supervised process and you want decoupled, type-based dispatch.
Next steps
- State machines with FSM to add state machine behavior to your Agents
- Plugins for the full Plugin API reference, including lifecycle hooks and child processes
- Sensors to learn how external events become Signals that Plugins can route