How to Implement Custom Directives in Jido for External Side Effects
You implement custom directives in Jido for external side effects by defining a struct, implementing the Jido.AgentServer.DirectiveExec protocol, and returning the directive from your agent's cmd/2 function.
Jido is an Elixir framework for building autonomous agents that enforces a strict separation between pure business logic and impure side effects. While the core library provides built-in directives like Emit, Spawn, and Schedule, you can extend the system with your own directives to handle external operations such as HTTP calls, database writes, or LLM invocations without modifying the core agentjido/jido codebase.
Architecture of Custom Directives in Jido
Jido's directive system follows the Elm/Redux pattern where effects are described by data rather than executed directly. The runtime handles all side effects, keeping your agent code deterministic and testable.
| Component | Role | How It Relates to Custom Directives |
|---|---|---|
Jido.Agent.Directive |
Holds definitions of core directives and documents the extensibility contract. | Shows how a custom struct can be added in a separate namespace (e.g., MyApp.Directive.CallLLM). |
Jido.AgentServer.DirectiveExec |
Protocol that executes directives. Implementations are looked up by the directive's struct type. | You implement exec/3 for your struct to translate the directive into real side effects. |
Jido.AgentServer.DirectiveExecutors |
Provides concrete implementations for built-in directives and a fallback for unknown types. | Serves as a reference implementation showing the pattern to follow for your own directive. |
Jido.AgentServer.State |
Manages how directives are enqueued and how the server maintains the queue. | Useful when you need to manipulate state inside your executor. |
Step-by-Step: Implement Custom Directives in Jido for External Side Effects
1. Define the Directive Struct
Create a struct that describes the external effect you want to perform. While optional, using Zoi for schema validation follows the pattern used by core directives in lib/jido/agent/directive.ex.
defmodule MyApp.Directive.CallLLM do
@moduledoc """
Directive to call a Large Language Model (LLM) service.
"""
@schema Zoi.struct(
__MODULE__,
%{
model: Zoi.any(description: "LLM model name") |> Zoi.optional(),
prompt: Zoi.string(description: "Prompt to send to the LLM"),
tag: Zoi.any(description: "Optional correlation tag") |> Zoi.optional()
},
coerce: true
)
@type t :: unquote(Zoi.type_spec(@schema))
@enforce_keys Zoi.Struct.enforce_keys(@schema)
defstruct Zoi.Struct.struct_fields(@schema)
@doc false
def schema, do: @schema
end
2. Implement the DirectiveExec Protocol
Implement the Jido.AgentServer.DirectiveExec protocol for your struct in lib/jido/agent_server/directive_exec.ex. The exec/3 function receives the directive, the triggering signal, and the current server state. It must return one of three allowed tuples:
{:ok, state}for synchronous completion{:async, ref | nil, state}for asynchronous operations{:stop, reason, state}to halt the server
defimpl Jido.AgentServer.DirectiveExec, for: MyApp.Directive.CallLLM do
@moduledoc false
require Logger
def exec(%{model: model, prompt: prompt, tag: tag}, _input_signal, state) do
# Example: fire-and-forget an async LLM call
Task.Supervisor.start_child(
Jido.TaskSupervisor,
fn ->
result = MyApp.LLMClient.call(model || "default-model", prompt)
# When the LLM finishes, emit a signal back to the agent:
signal = Jido.Signal.new!("myapp.llm.result", %{result: result, tag: tag})
Jido.AgentServer.cast(self(), signal)
end
)
# No immediate state change – the async work will later emit a signal.
{:async, nil, state}
end
end
Reference implementations for built-in directives are available in lib/jido/agent_server/directive_executors.ex, which demonstrates patterns for handling Emit, Spawn, and other core directives.
3. Emit the Directive from Your Agent
Return your custom directive from the agent's cmd/2 function (or from a strategy) just as you would return a built-in directive. The runtime automatically dispatches it to your protocol implementation.
defmodule MyApp.Agent.Example do
use Jido.Agent, name: "example"
@impl true
def cmd(agent, {:ask_llm, prompt}) do
# Return the directive together with the unchanged agent state
directive = %MyApp.Directive.CallLLM{prompt: prompt, tag: :question_1}
{agent, [directive]}
end
# Handle the LLM result signal (optional)
@impl true
def handle_signal(agent, %Jido.Signal{name: "myapp.llm.result", payload: %{result: r, tag: _}}) do
# Update state with the answer, then continue
new_state = Map.put(agent.state, :last_answer, r)
{%{agent | state: new_state}, []}
end
end
The agent's cmd/2 remains a pure function—it merely describes the external effect. The Jido.AgentServer runtime handles the actual execution via your DirectiveExec implementation, maintaining the separation between pure logic and side effects.
Key Source Files for Custom Directive Implementation
| File | Why It Matters |
|---|---|
[lib/jido/agent/directive.ex](https://github.com/agentjido/jido/blob/main/lib/jido/agent/directive.ex) |
Defines core directives and documents the extensibility contract showing how custom structs fit into the system. |
[lib/jido/agent_server/directive_exec.ex](https://github.com/agentjido/jido/blob/main/lib/jido/agent_server/directive_exec.ex) |
Protocol definition (Jido.AgentServer.DirectiveExec) that you implement for custom directives; specifies the exec/3 signature and return values. |
[lib/jido/agent_server/directive_executors.ex](https://github.com/agentjido/jido/blob/main/lib/jido/agent_server/directive_executors.ex) |
Concrete implementations for built-in directives; serves as the reference pattern for your own defimpl. |
[lib/jido/agent_server/state.ex](https://github.com/agentjido/jido/blob/main/lib/jido/agent_server/state.ex) |
Shows how directives are enqueued and how server state is maintained; useful when manipulating state within custom executors. |
These files provide the complete context required to extend Jido with your own side-effect directives without modifying the core library.
Summary
- Jido directives are plain structs that describe external side effects, keeping agent logic pure and testable.
- To implement custom directives in Jido for external side effects, define a struct, implement the
Jido.AgentServer.DirectiveExecprotocol, and return the directive fromcmd/2. - The protocol's
exec/3function must return{:ok, state},{:async, ref, state}, or{:stop, reason, state}to control execution flow. - Custom directives require no changes to the core
agentjido/jidocodebase—the protocol automatically dispatches to your implementation based on the struct type.
Frequently Asked Questions
What is the difference between a directive and a signal in Jido?
A signal represents an event or message flowing through the system, often triggering state transitions, while a directive is a declarative description of an external side effect that the runtime should execute. Signals are processed by the agent's handle_signal/2 function, whereas directives are executed by the Jido.AgentServer.DirectiveExec protocol implementations.
Can I implement async operations in custom directives?
Yes, the Jido.AgentServer.DirectiveExec protocol explicitly supports asynchronous operations. Your exec/3 implementation can start background work using Task.Supervisor.start_child/2 and return {:async, ref, state} where ref is an optional reference to the async task. When the async work completes, you can emit a signal back to the agent using Jido.AgentServer.cast/2.
Do I need to modify Jido's core code to add custom directives?
No, you never need to modify the core agentjido/jido codebase. The DirectiveExec protocol is open-ended and uses Elixir's protocol dispatch to automatically route your custom struct to your implementation. Simply define your struct and defimpl in your own application or library, and the runtime will handle the rest.
How do I test custom directives in Jido?
Test custom directives by verifying both the struct creation and the protocol implementation. Unit tests should assert that your exec/3 function returns the correct tuples ({:ok, state}, {:async, _, state}, etc.) and that it correctly manipulates the server state. Integration tests can verify that when your directive is returned from cmd/2, the runtime correctly dispatches to your implementation, often by capturing the side effects or mocking the external calls.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →