Tools And Operations

Copy Markdown View Source

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.Action and 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:

  1. Operation metadata (Jidoka.Agent.Spec.Operation) is pure data the model sees: name, description, parameter schema (in metadata), idempotency policy, and a free-form metadata map that includes the originating source (action, ash_resource, browser, mcp, subagent, handoff, workflow, local) and a kind tag used for control matching.
  2. Runtime capability is a 2-arity function Jidoka calls with the Jidoka.Effect.Intent and the current Jidoka.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?/1 is 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}}
end

Add it to the agent:

tools do
  action MyApp.Tools.Echo
end

The 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]
end

The 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"]
end

Each 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_definitions

preflight 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.Action for production tools. It gives schema validation, consistent error shapes, and Jido instrumentation for free.
  • Use Jidoka.Operation.Source.Local for 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 matching when: [kind: :transfer] work without surprise.
  • Default to :idempotent. Reserve :unsafe_once for 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
end

Jidoka.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

SymptomLikely CauseFix
{: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 timeTwo DSL entries produced the same operation name.Rename one entry (as: :other_name on subagent/handoff, or pick a different action).
Operation control never fireswhen: 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 argumentsSchema 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