An introductory, hello-world style project for learning the Jido autonomous agent framework for Elixir.
Jido (自動 — Japanese for "automatic") is an Elixir framework for building autonomous, stateful agents. The core idea is that your program is organized as a collection of agents — each one owning a piece of state — and actions — pure, validated functions that transform that state.
This might sound abstract, but the key insight is the separation of concerns:
- Actions hold your business logic. They're pure functions that say what should change.
- Agents hold your state. They say what data we're tracking.
- Directives describe side effects. They say what should happen in the outside world as a result of an action — spawning a process, emitting a signal, scheduling future work, etc.
This separation makes logic easy to test in isolation (actions are just functions), keeps state changes predictable and auditable, and sets you up for building more complex multi-agent systems later.
- Elixir 1.17+ and Erlang/OTP 26+
- This project was built with Elixir 1.19.5 / OTP 28
git clone <this repo>
cd jido_poc
mix deps.get
iex -S mixThen try the demos:
iex> JidoPoc.hello_greeter()
iex> JidoPoc.hello_counter()
iex> JidoPoc.hello_pipeline()lib/
jido_poc.ex ← runnable demos
jido_poc/
actions/
say_hello.ex ← Action: generates a greeting
increment.ex ← Action: adds to a counter
agents/
greeter.ex ← Agent: tracks greeting history
counter.ex ← Agent: tracks a numeric count
An Action is the fundamental unit of work in Jido. Think of it as a validated, self-describing function. You define it with use Jido.Action, give it a parameter schema, and implement a run/2 callback.
# lib/jido_poc/actions/say_hello.ex
defmodule JidoPoc.Actions.SayHello do
use Jido.Action,
name: "say_hello",
description: "Generates a greeting for the given name",
schema: [
name: [type: :string, required: true],
style: [type: {:in, [:formal, :casual]}, default: :casual]
]
@impl true
def run(%{name: name, style: :casual}, _context) do
{:ok, %{last_greeting: "Hey, #{name}!"}}
end
def run(%{name: name, style: :formal}, _context) do
{:ok, %{last_greeting: "Good day, #{name}."}}
end
endA few things to notice:
The schema is validated before run/2 is called. If you pass the wrong type, a missing required field, or a value outside the allowed set, Jido rejects it before your function ever runs. There's no defensive validation code needed inside run/2.
run/2 receives (params, context).
paramsis the validated, atom-keyed map of inputs. You can pattern match on it directly (as shown above).contextcarries the agent's current state and other metadata. If your action needs to read existing state to compute the next state (likeIncrementdoes), you access it viacontext.state.
The return value is a map of state updates.
Returning {:ok, %{last_greeting: "Hey, Alice!"}} tells Jido to merge that map into the agent's state. You only return the keys you want to change; everything else stays the same.
Here's Increment, which does need to read existing state:
# lib/jido_poc/actions/increment.ex
defmodule JidoPoc.Actions.Increment do
use Jido.Action,
name: "increment",
description: "Adds `amount` to the agent's counter",
schema: [
amount: [type: :integer, default: 1]
]
@impl true
def run(%{amount: amount}, context) do
current = context.state[:count] || 0
{:ok, %{count: current + amount}}
end
endcontext.state[:count] reads the current value out of the agent's state map, adds amount to it, and returns the new value. Simple and composable.
An Agent owns a piece of state. You define it with use Jido.Agent, declare the shape of its state via a schema, and register which actions it can run.
# lib/jido_poc/agents/counter.ex
defmodule JidoPoc.Agents.Counter do
use Jido.Agent,
name: "counter",
description: "Counts things",
schema: [
count: [type: :integer, default: 0]
],
actions: [JidoPoc.Actions.Increment]
endThe schema here uses the same NimbleOptions syntax as Actions. It defines the fields that live in agent.state, their types, and their defaults. Jido validates the state against this schema whenever it changes, so you can't accidentally put a string where an integer belongs.
Agents are immutable structs. Counter above is a plain Elixir struct, not a process. You create one with new/1 and update it by calling cmd/2, which returns a new struct with updated state — it doesn't mutate anything in place. This makes agents easy to reason about and test.
Once you have an agent and an action, you run them together with cmd/2:
agent = Counter.new()
# agent.state.count => 0
{agent, _directives} = Counter.cmd(agent, {Increment, %{amount: 10}})
# agent.state.count => 10
{agent, _directives} = Counter.cmd(agent, {Increment, %{amount: 5}})
# agent.state.count => 15cmd/2 returns a two-element tuple: the updated agent struct, and a list of directives.
The updated agent is what you care about most when starting out. You thread it from call to call — each cmd receives the agent returned by the previous one.
Directives are instructions for the Jido runtime describing side effects: spawn a child process, emit a signal to another agent, schedule something for later, and so on. For simple synchronous use like this, you can ignore them with _directives. They become important once you move to supervised agent processes.
You can pass a list of {Action, params} tuples to cmd/2 and they run sequentially as a pipeline. Each action sees the state produced by the one before it:
{agent, _} =
Counter.cmd(agent, [
{Increment, %{amount: 1}},
{Increment, %{amount: 2}},
{Increment, %{amount: 3}}
])
agent.state.count #=> 6This is useful for expressing multi-step workflows atomically — either all steps succeed, or the whole pipeline fails.
Because agents are plain structs, you can inspect their state directly at any point:
agent = Greeter.new()
IO.inspect(agent.state)
# => %{greeting_count: 0, last_greeting: nil}
{agent, _} = Greeter.cmd(agent, {SayHello, %{name: "Alice"}})
agent.state.last_greeting
# => "Hey, Alice!"There's no hidden state, no message passing to a process, no unwrapping of GenServer replies. The agent is just a struct, and .state is just a map.
This POC covers the synchronous, pure-functional layer of Jido. Once you're comfortable with it, the natural next steps are:
| Topic | What it unlocks |
|---|---|
Jido.AgentServer |
Run an agent as a supervised OTP process — persistent, fault-tolerant, callable over message passing |
| Signals | Agent-to-agent messaging built on the CloudEvents spec. Agents communicate by emitting and routing signals rather than calling each other directly |
| Directives | Returning Jido.Directive.Emit or Jido.Directive.Spawn from an action to trigger side effects |
jido_ai |
Drop-in actions for calling LLMs, with tool-calling support that maps Jido actions directly to LLM tool schemas |