Tools are model-callable work. Jidoka gives the model one operation contract: name, description, parameters, and metadata. Actions, Ash resources, browser tools, MCP tools, workflows, and subagents all compile to that shape.
Use This When
- authoring a new tool for an agent;
- debugging "the model called the wrong operation" or "the operation handler was not found".
- writing deterministic tests that need a known set of operations.
- skip this guide for memory writes; use Memory.
Prerequisites
- A working Jidoka project (see Getting Started).
- Familiarity with
Jido.Actionand Zoi schemas. - A provider key in scope for live examples.
mix deps.get
mix test
Define A Tool
The minimum example is one Jidoka.Action and one DSL agent that lists it.
defmodule MyApp.Tools.LocalTime do
use Jidoka.Action,
name: "local_time",
description: "Returns the local time for a city.",
schema: Zoi.object(%{city: Zoi.string() |> Zoi.default("Chicago")})
@impl true
def run(params, _context) do
city = Map.get(params, :city) || Map.get(params, "city") || "Chicago"
{:ok, %{city: city, time: "09:30"}}
end
end
defmodule MyApp.TimeAgent do
use Jidoka.Agent
agent :time_agent do
model "openai:gpt-4o-mini"
instructions "Call local_time when asked for the time."
end
tools do
action MyApp.Tools.LocalTime
end
end
{:ok, text} = MyApp.TimeAgent.chat("What time is it in Chicago?")The model sees an operation named "local_time". If it calls the operation,
Jidoka runs MyApp.Tools.LocalTime, sends the result back to the model, and
returns the final answer.
Concepts
A Jidoka operation has two parts:
- Operation metadata (
Jidoka.Agent.Spec.Operation) is pure data the model sees: name, description, parameter schema (inmetadata), idempotency policy, and a free-formmetadatamap that includes the originatingsource(action, ash_resource, browser, mcp, subagent, handoff, workflow, local) and akindtag used for control matching. - Runtime capability is a 2-arity function Jidoka calls with the
Jidoka.Effect.Intentand the currentJidoka.Effect.Journal. Its job is to resolve the call to{:ok, output}or{:error, reason}.
╭───────────────╮ ╭──────────────────────╮ ╭────────────────╮
│ tools block │────▶│ Agent.Spec.Operation │────▶│ Model decision │
│ (or source) │ │ (name + metadata) │ ╰────────┬───────╯
╰───────┬───────╯ ╰──────────────────────╯ │
│ │ {:operation,
│ │ name, args}
▼ ▼
╭───────────────────────╮ ╭────────────────────╮
│ Runtime capability fn │◀──────────────────────│ Operation request │
│ (intent, journal) │ Jidoka invokes │ │
╰───────────┬───────────╯ ╰────────────────────╯
│
▼
{:ok, output} | {:error, reason}The model asks for {:operation, name, arguments}. Jidoka executes the
operation and records the result in the turn journal before asking the model
for the final answer.
Operation Kinds And Matching
Jidoka.Agent.Spec.Operation.kind/1 returns one of :action, :operation,
:tool, :ash_resource, :browser, :skill, :mcp, :workflow,
:subagent, :handoff. Operation controls in the controls
block match against kind, name, source, idempotency, and arbitrary
metadata keys:
operation MyApp.RequireApproval,
when: [kind: :handoff]
operation MyApp.LogTransfers,
when: [name: :transfer_funds, idempotency: :unsafe_once]
operation MyApp.SourceGuard,
when: [source: "mcp"]The first matching control wins per intent. See Controls for the policy decisions a control can return.
Idempotency Policies (Overview)
Every operation declares one of:
:pure- safe to call repeatedly, no side effects.:idempotent- default; safe to retry, the runtime may de-duplicate by payload.:dedupe- the operation expects the runtime to skip duplicate intents inside a turn.:reconcile- the runtime should re-derive the result from authoritative state on replay.:unsafe_once- the operation must run at most once; replay requires a recorded result.Operation.requires_control?/1is true for this kind, so declaring an explicit operation control is recommended.
This guide covers the authoring path. Full idempotency, pause/resume, and replay behavior live in Runtime And Harness.
How To
Step 1: Author A Tool With Jidoka.Action
Jidoka.Action wraps Jido.Action. The :name and
:schema are what the model sees; everything else feeds into the
Agent.Spec.Operation metadata.
defmodule MyApp.Tools.Echo do
use Jidoka.Action,
name: "echo",
description: "Echoes a phrase back.",
schema: Zoi.object(%{phrase: Zoi.string()})
@impl true
def run(%{phrase: phrase}, _context), do: {:ok, %{echoed: phrase}}
endAdd it to the agent:
tools do
action MyApp.Tools.Echo
endThe compiled spec now includes:
%Jidoka.Agent.Spec.Operation{
name: "echo",
description: "Echoes a phrase back.",
idempotency: :idempotent,
metadata: %{"source" => "jido_action", "kind" => "action", ...}
}Step 2: Match Operations With Controls
Operation controls run before the runtime executes the capability. They gate, log, or interrupt model-chosen calls.
defmodule MyApp.NoExternalBrowser do
use Jidoka.Control, name: "no_external_browser"
@impl true
def call(%Jidoka.Runtime.Controls.OperationContext{} = op) do
if op.metadata["source"] == "browser", do: {:block, :browser_blocked}, else: :cont
end
end
controls do
operation MyApp.NoExternalBrowser, when: [kind: :browser]
endThe when map can mix :kind, :name, :source, :idempotency, and any
key inside metadata. Matching is exact string/atom comparison.
Step 3: Expose Local Capabilities In Tests
The fastest way to provide operations without writing modules is
Jidoka.Operation.Source.Local. It compiles a list of {name, handler}
entries into both the operation metadata and a runtime capability.
{:ok, %{operations: operations, capability: capability}} =
Jidoka.Operation.Source.compile(
Jidoka.Operation.Source.Local.new!(
operations: [
%{name: "local_time", handler: fn _args -> {:ok, %{time: "09:30"}} end},
%{name: "echo", handler: fn %{"phrase" => phrase} -> {:ok, %{echoed: phrase}} end}
]
)
)
spec =
Jidoka.agent!(
id: "ops_demo",
model: "openai:gpt-4o-mini",
instructions: "Use the available operations.",
operations: operations
)
llm = fn _intent, journal ->
case map_size(journal.results) do
0 -> {:ok, %{type: :operation, name: "echo", arguments: %{"phrase" => "hi"}}}
_ -> {:ok, %{type: :final, content: "done"}}
end
end
{:ok, result} = Jidoka.turn(spec, "ping", llm: llm, operations: capability)
result.content
#=> "done"Local handlers may be (args -> term) or (intent, journal -> term). A bare
term return value is wrapped in {:ok, value}.
Step 4: Use Source-Backed Tools
The DSL exposes higher-level sources that all compile to operations:
tools do
action MyApp.Tools.LocalTime
ash_resource MyApp.Accounts.User, actions: [:read]
browser :docs, allow: ["docs.example.com"]
endEach entry contributes one or more Agent.Spec.Operation entries with
distinct names. Duplicate operation names are a compile error.
Step 5: Inspect The Resulting Operations
Before you spend a token, check the compiled operations and metadata:
Jidoka.inspect(MyApp.TimeAgent).spec.operations
#=> [%{name: "local_time", idempotency: :idempotent, metadata: %{"kind" => "action", ...}}]
{:ok, preflight} = Jidoka.preflight(MyApp.TimeAgent, "What time is it?")
preflight.prompt.tool_definitionspreflight shows exactly what the prompt assembler will hand the model, so
you can confirm names, descriptions, and parameter schemas line up with what
the LLM expects.
Common Patterns
- One operation per side effect. Smaller operations match better and control rules read more clearly.
- Use
Jidoka.Actionfor production tools. It gives schema validation, consistent error shapes, and Jido instrumentation for free. - Use
Jidoka.Operation.Source.Localfor tests and one-off demos. It removes module ceremony and keeps the LLM/operation contract obvious. - Tag your operations. A short
metadata: %{"kind" => :transfer}makes control matchingwhen: [kind: :transfer]work without surprise. - Default to
:idempotent. Reserve:unsafe_oncefor genuinely irreversible side effects so Jidoka can require approval before running them.
Testing
A deterministic operation test pins both the LLM decision and the operation result. The runtime never reaches a provider.
defmodule MyApp.TimeAgentTest do
use ExUnit.Case, async: true
test "uses the local_time operation" do
operations =
Jidoka.Runtime.LocalOperations.operations(%{
"local_time" => fn %{"city" => city} -> {:ok, %{city: city, time: "09:30"}} end
})
llm = fn _intent, journal ->
case map_size(journal.results) do
0 -> {:ok, %{type: :operation, name: "local_time", arguments: %{"city" => "Chicago"}}}
_ -> {:ok, %{type: :final, content: "Chicago time is 09:30."}}
end
end
assert {:ok, result} =
Jidoka.turn(MyApp.TimeAgent, "What time is it?",
llm: llm,
operations: operations
)
assert result.content =~ "09:30"
[operation_result] =
result.agent_state.operation_results
assert operation_result.operation == "local_time"
end
endJidoka.Runtime.LocalOperations.operations/1 is the test helper for
building an operation capability from a map of handlers. The same helper
backs Jidoka.Operation.Source.Local.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
{:error, {:missing_operation_handler, name}} | The LLM chose an operation no capability resolves. | Add the handler to the registered capability, or restrict the prompt so the model cannot pick it. |
{:error, {:unsupported_effect_kind, kind}} | A capability was handed an intent it does not understand. | Make sure the capability matches the intent kind (:operation); chain capabilities with a router if you serve multiple kinds. |
tool :name is defined more than once at compile time | Two DSL entries produced the same operation name. | Rename one entry (as: :other_name on subagent/handoff, or pick a different action). |
| Operation control never fires | when: did not match kind/name/source/metadata. | Inspect Jidoka.inspect(agent).spec.operations to see the exact metadata, then mirror the keys in when:. |
Live model picks invalid arguments | Schema in the action does not match the LLM-facing description. | Tighten the schema or update the description; preflight shows the JSON-schema the model receives. |
Reference
Jidoka.Agent.Spec.Operation- operation data,kind/1,requires_control?/1,replay_safe?/1.Jidoka.Operation.Source- behaviour for sources that compile to operations plus a capability.Jidoka.Operation.Source.Local- the function-backed source used by tests and examples.Jidoka.Runtime.LocalOperations- capability builder for raw handler maps.Jidoka.Action- the Jido action wrapper used in production tools.- Tool-source compiler - internal compiler from DSL entries to operations and capabilities.
Related Guides
- Agent DSL - the DSL that owns the
toolsblock. - Controls - input/operation/output policy and approvals.
- Handoffs - the handoff source and conversation ownership.
- Testing And Evals - golden DSL-to-spec tests and
the
Jidoka.Evalrunner. - Inspection And Preflight - debugging the compiled operations and prompt before running a turn.