Skip to main content

guides/agent-dsl.md

# Agent DSL

The Jidoka DSL is small. It compiles agent modules to `Jidoka.Agent.Spec`.

## Minimal Agent

```elixir
defmodule MyApp.Assistant do
  use Jidoka.Agent

  agent :assistant
end
```

This uses default instructions and the configured default model.

## Agent Block

```elixir
agent :support_agent do
  model "openai:gpt-4o-mini"
  instructions "Answer support questions tersely."
  context Zoi.object(%{tenant_id: Zoi.string()})
  result schema: Zoi.object(%{answer: Zoi.string()}), max_repairs: 1
  memory scope: :session, max_entries: 5
end
```

Supported fields:

- `model` - any ReqLLM/LLMDB model input, such as `"openai:gpt-4o-mini"` or
  `%{provider: :openai, id: "gpt-4o-mini"}`;
- `generation` - optional provider-facing generation overrides; omitted agents
  use `Jidoka.Config.default_generation/0`;
- `instructions` - system-level behavior instructions;
- `context` - optional Zoi schema for runtime context validation;
- `result` - optional Zoi schema or `Jidoka.Agent.Spec.Result` data for
  structured app-facing turn results;
- `memory` - optional memory policy data, or `true` for defaults.

When `result` is declared, models still return final assistant text, but the
runtime also validates the final structured value and exposes it as
`Jidoka.Turn.Result.value`. Invalid values trigger a bounded repair loop using
`max_repairs`; after the bound is exhausted, the turn fails with a result-phase
error.

## Tools Block

```elixir
tools do
  action MyApp.LocalTime
  ash_resource MyApp.Accounts.Customer, actions: [:read, :create]
  browser :docs, allow: ["https://docs.example.com"]
  mcp_tools endpoint: :support_mcp, prefix: "support_"
  skill MyApp.Skills.RefundPolicy
  load_path "priv/skills"
  workflow MyApp.Workflows.RefundQuote, as: :quote_refund
  subagent MyApp.EvidenceAgent, as: :collect_evidence
  handoff MyApp.BillingAgent, as: :billing_specialist
end
```

The `tools` block is authoring vocabulary for anything the model can ask
Jidoka to do. Every entry compiles to one or more `Agent.Spec.Operation`
records. The runtime still sees a small operation boundary; the DSL gives you
clear source-specific syntax.

Supported entries:

| DSL entry | Use it for | Runtime shape |
| --- | --- | --- |
| `action MyApp.Action` | One deterministic Jido/Jidoka action. | One `:action` operation. |
| `ash_resource MyApp.Resource` | AshJido-generated actions from an Ash resource. | One operation per exposed Ash action. |
| `browser :docs` | Search/read/snapshot browser capabilities backed by `jido_browser`. | `:browser` operations. |
| `mcp_tools endpoint: :id` | Tools exposed by a configured MCP endpoint. | One `:mcp` operation per discovered/static tool. |
| `skill MyApp.Skill` | Jido.AI skill instructions and any declared operations. | Skill prompt plus `:skill` operations when present. |
| `load_path "priv/skills"` | Runtime-loaded `SKILL.md` files. | Adds skill instructions from the path. |
| `workflow MyApp.Workflow` | Deterministic application workflow as one callable operation. | One `:workflow` operation. |
| `subagent MyApp.Agent` | Bounded delegation to another Jidoka agent for one task. | One `:subagent` operation. |
| `handoff MyApp.Agent` | Transfer future conversation ownership to another agent. | One `:handoff` operation. |

`action` is the smallest tool source. The module must be a Jido action or a
compatible module exposing `to_tool/0`.

```elixir
tools do
  action MyApp.LocalTime
end
```

`ash_resource` records an Ash resource source. AshJido-generated actions are
imported as `:ash_resource` operations. Use `actions:` to filter the generated
operations exposed to the model:

```elixir
tools do
  ash_resource MyApp.Accounts.User, actions: [:read, :create]
end
```

`browser` expands to constrained browser operations backed by Jido action
wrappers for the `jido_browser` read-only tools: `search_web`, `read_page`, and
`snapshot_url` in `:read_only` mode.

```elixir
tools do
  browser :docs, mode: :read_only, allow: ["https://hexdocs.pm"]
end
```

`mcp_tools` imports tools from a configured MCP endpoint. Use `prefix:` to keep
remote tool names distinct from local operation names.

```elixir
tools do
  mcp_tools endpoint: :support_mcp,
            prefix: "support_",
            tools: [%{name: "lookup_policy", description: "Look up support policy."}]
end
```

`skill` and `load_path` add Jido.AI skill context. A skill can add prompt
instructions, operation metadata, or both depending on the skill definition.

```elixir
tools do
  skill MyApp.Skills.RefundPolicy
  load_path "priv/skills"
end
```

`workflow` exposes deterministic application code as one operation. Use it when
the model should choose a business workflow, but your application owns the
workflow steps.

```elixir
tools do
  workflow MyApp.Workflows.RefundQuote,
    as: :quote_refund,
    timeout: 10_000,
    result: :structured
end
```

`subagent` delegates one bounded task to another Jidoka agent and returns the
child result to the parent. It does not change who owns the next user turn.

```elixir
tools do
  subagent MyApp.EvidenceAgent,
    as: :collect_evidence,
    timeout: 30_000,
    result: :structured
end
```

`handoff` records that another agent should own future turns for a conversation.
The current turn still completes normally; your application reads
`Jidoka.handoff/1` to route the next message.

```elixir
tools do
  handoff MyApp.BillingAgent,
    as: :billing_specialist,
    target: :auto,
    forward_context: :public
end
```

## Controls Block

Controls describe policy at explicit turn boundaries:

- `input` runs before the first model call;
- `operation` describes policy around model-callable work;
- `output` runs before the final answer is returned;
- `max_turns` and `timeout` bound the turn loop.

```elixir
defmodule MyApp.NoSecrets do
  use Jidoka.Control, name: "no_secrets"

  @impl true
  def call(%{input: input}) do
    if String.contains?(input, "secret"), do: {:block, :secret_input}, else: :cont
  end
end

defmodule MyApp.RequireApproval do
  use Jidoka.Control, name: "require_approval"

  @impl true
  def call(%Jidoka.Runtime.Controls.OperationContext{} = operation) do
    if operation.operation == "local_time" do
      {:interrupt, :approval_required}
    else
      :cont
    end
  end
end

defmodule MyApp.SafeReply do
  use Jidoka.Control, name: "safe_reply"

  @impl true
  def call(_result), do: :cont
end

controls do
  max_turns 8
  timeout 30_000

  input MyApp.NoSecrets

  operation MyApp.RequireApproval,
    when: [kind: :action, name: :local_time]

  output MyApp.SafeReply
end
```

Input, operation, and output controls run in declaration order. Operation controls receive
`Jidoka.Runtime.Controls.OperationContext` data and may return `:cont`,
`{:block, reason}`, `{:interrupt, reason}`, or `{:error, reason}`.

An operation interrupt hibernates the turn with a `:review` cursor and a
`Jidoka.Review.Request` in `snapshot.metadata["pending_review"]`.

See [Controls](controls.md) for approval, limit, and testing examples.

## Compiled Shape

The important boundary is the compiled spec:

```elixir
%Jidoka.Agent.Spec{
  id: "support_agent",
  model: %LLMDB.Model{},
  generation: %Jidoka.Agent.Spec.Generation{},
  context_schema: %Zoi.Schema{},
  result: %Jidoka.Agent.Spec.Result{},
  operations: [%Jidoka.Agent.Spec.Operation{}],
  controls: %Jidoka.Agent.Spec.Controls{}
}
```

Golden tests in `test/jidoka/golden/dsl_to_spec_test.exs` lock the projected
shape.

## Import Parity

JSON/YAML imports compile into the same `Jidoka.Agent.Spec` shape. Portable
documents stay data-only, so action modules and Zoi context schemas are named in
the document and resolved with registries. Import currently covers the data-safe
agent fields, controls, and the portable tool sources: `action`,
`ash_resource`, `browser`, and `mcp_tools`.

```yaml
agent:
  id: support_agent
  model: openai:gpt-4o-mini
  instructions: Answer support questions tersely.
  context:
    ref: support_context
  result:
    ref: support_result
    max_repairs: 1
tools:
  actions:
    - local_time
  ash_resources:
    - ref: account_resource
      actions:
        - read_account
  browsers:
    - name: docs
      mode: search
      allow:
        - docs.example.com
  mcp_tools:
    - endpoint: support_mcp
      prefix: support_
      tools:
        - name: lookup_policy
          description: Look up support policy.
controls:
  max_turns: 8
  timeout: 30000
  inputs:
    - control: no_secrets
  operations:
    - control: require_approval
      when:
        kind: action
        name: local_time
  outputs:
    - control: safe_reply
```

```elixir
yaml = """
agent:
  id: support_agent
  model: openai:gpt-4o-mini
  instructions: Answer support questions tersely.
  context:
    ref: support_context
  result:
    ref: support_result
    max_repairs: 1
tools:
  actions:
    - local_time
  browsers:
    - name: docs
      mode: search
controls:
  max_turns: 8
  timeout: 30000
  inputs:
    - control: no_secrets
  operations:
    - control: require_approval
      when:
        kind: action
        name: local_time
  outputs:
    - control: safe_reply
"""

{:ok, spec} =
  Jidoka.import(yaml,
    registries: %{
      actions: %{"local_time" => MyApp.LocalTime},
      ash_resources: %{"account_resource" => MyApp.AccountResource},
      controls: %{
        "no_secrets" => MyApp.NoSecrets,
        "require_approval" => MyApp.RequireApproval,
        "safe_reply" => MyApp.SafeReply
      },
      context_schemas: %{"support_context" => Zoi.object(%{tenant_id: Zoi.string()})},
      result_schemas: %{"support_result" => Zoi.object(%{answer: Zoi.string()})}
    }
  )
```

String refs are resolved only through explicit registries; imports do not create
atoms or modules from untrusted input.

## Not In The DSL Yet

The current DSL does not expose session queues, approval queues, or native
provider tool-calling. Runtime additions remain explicit Elixir code, not
agent DSL.