Use JSON or YAML when agents are authored outside Elixir. Jidoka imports the document into the same Jidoka.Agent.Spec the DSL produces. Executable values (action modules, control modules, Zoi schemas, Ash resources) resolve only through caller-supplied registries, so imports never call String.to_atom/1 on untrusted input.

When To Use This

  • Use this guide when agents are authored outside Elixir (admin UI, config bundle, content repo).
  • Use this guide when shipping agents as portable JSON/YAML that operations teams can edit.
  • Do not use this guide when modules are in your code anyway; the DSL is shorter and gives you compile-time validation.
  • Do not use this guide for arbitrary user-uploaded YAML without a trust story; the import boundary requires deliberate registries.

Prerequisites

  • A working Jidoka project (see Getting Started).
  • Familiarity with the operation contract from Tools And Operations.
  • For YAML: the :yaml_elixir dependency is already brought in by Jidoka.
  • A provider key in scope for the live chat/3 example. Tests can inject a fake LLM instead.
mix deps.get
mix test

Quick Example

The smallest portable agent is one YAML document plus an actions registry.

defmodule MyApp.Tools.LocalTime do
  use Jidoka.Action,
    name: "local_time",
    description: "Returns the local time.",
    schema: Zoi.object(%{city: Zoi.string() |> Zoi.default("Chicago")})

  @impl true
  def run(_params, _context), do: {:ok, %{city: "Chicago", time: "09:30"}}
end

yaml = """
version: 1
agent:
  id: time_agent
  model: openai:gpt-4o-mini
  instructions: Call local_time when asked for the time.
tools:
  actions:
    - local_time
"""

{:ok, spec} =
  Jidoka.import(yaml,
    actions: %{"local_time" => MyApp.Tools.LocalTime}
  )

{:ok, answer} = Jidoka.chat(spec, "What time is it in Chicago?")
answer

The same spec value comes back regardless of whether the agent was authored in Elixir, JSON, or YAML.

Concepts

The import surface is three layers and one trust boundary.

  1. Jidoka.Import.AgentDocument is the on-the-wire shape, validated by a Zoi schema. The document has a version (currently 1), an agent block, and optional tools, controls, operations, runtime_defaults, and metadata blocks.
  2. Jidoka.Import is the public compiler. It accepts a JSON or YAML string (or a decoded map), normalizes the document, resolves every non-data reference through caller-supplied registries, and produces a Jidoka.Agent.Spec.
  3. Registries are plain maps or keyword lists keyed by name. Five registries are supported: actions, ash_resources, controls, context_schemas, result_schemas. The trust boundary is here: only refs the caller put in the registry can become live modules or schemas.
╭───────────────────╮     ╭──────────────────────╮     ╭─────────────────╮
│ JSON / YAML       │────▶│ Jidoka.Import.import │────▶│ AgentDocument   │
│ (string or map)   │     │   (format detected)  │     │ (Zoi validated) │
╰───────────────────╯     ╰──────────┬───────────╯     ╰────────┬────────╯
                                     │                          │
                                     ▼                          ▼
                          ╭──────────────────────╮     ╭─────────────────╮
                          │ Registries           │────▶│ Spec attrs      │
                          │ - actions            │     │ - operations    │
                          │ - ash_resources      │     │ - controls      │
                          │ - controls           │     │ - context_schema│
                          │ - context_schemas    │     │ - result        │
                          │ - result_schemas     │     │ - memory etc.   │
                          ╰──────────────────────╯     ╰────────┬────────╯
                                                                │
                                                                ▼
                                                     ╭──────────────────╮
                                                     │ Jidoka.Agent.Spec│
                                                     │ (same as DSL)    │
                                                     ╰──────────────────╯

Versioning

Every document carries version: 1. The schema validates the version and returns {:error, {:unsupported_import_document_version, ...}} for anything else. Treat the version as the only authoring contract guarantee; new fields must be opt-in.

DSL/Import Parity

The DSL and the importer compile to the same Jidoka.Agent.Spec shape and the same Jidoka.Agent.Spec.Operation entries. Golden DSL-to-spec tests catch authoring drift early. When you add a feature to the DSL, add a matching key to the document schema. When you add a key to the document, add a matching DSL clause.

Trust Boundary

Imports never:

  • call String.to_atom/1 or Module.concat/1 on input;
  • load Elixir files from a path supplied by the document;
  • assume a default registry; missing refs are an error.

Imports always:

  • accept atoms or strings interchangeably in registry keys;
  • resolve action modules, control modules, Ash resources, context schemas, and result schemas through the registries you pass;
  • return Jidoka.Error.Invalid on any unknown ref so failures are typed.

How To

Step 1: Pick A Format

Jidoka.import/2 detects JSON when the string starts with { or [, and otherwise treats it as YAML. Force a format with format: :json or format: :yaml when the heuristic is wrong.

{:ok, spec} = Jidoka.import(json_string, format: :json, actions: actions)
{:ok, spec} = Jidoka.import(yaml_string, format: :yaml, actions: actions)

Step 2: Write The Document

The minimal document defines an agent block. Anything else is optional.

version: 1
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
  memory:
    scope: session
    capture: conversation
    max_entries: 8
tools:
  actions:
    - local_time
  ash_resources:
    - ref: account_resource
      actions:
        - read_account
  browsers:
    - name: docs
      mode: read_only
      allow:
        - docs.example.com
controls:
  max_turns: 8
  timeout: 30000
  inputs:
    - control: no_secrets
  operations:
    - control: require_approval
      when:
        kind: action
        name: local_time
  outputs:
    - control: safe_reply

Key shapes match the DSL: tools.actions is a list of action refs; controls.operations[].when is the same match map operation controls accept in the DSL.

Step 3: Build The Registries

Each registry is a map (or keyword list) keyed by name. Values are real Elixir modules or Zoi schemas the caller already trusts.

registries = %{
  actions: %{"local_time" => MyApp.Tools.LocalTime},
  ash_resources: %{"account_resource" => MyApp.Accounts.User},
  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()})}
}

{:ok, spec} = Jidoka.import(yaml, registries: registries)

Jidoka.Import also accepts the registries as top-level options: actions:, ash_resources:, controls:, context_schemas:, result_schemas:. The forms are equivalent; pick one per project.

Step 4: Handle Missing Refs

A missing ref returns a typed validation error. Match on it explicitly when you accept user-authored documents.

case Jidoka.import(yaml, actions: %{}) do
  {:ok, spec} ->
    {:ok, spec}

  {:error, %Jidoka.Error.Invalid{} = error} ->
    %{details: %{reason: reason}} = Jidoka.error_to_map(error)
    {:error, reason}
end

For the YAML above, that surfaces as {:unknown_registry_ref, :actions, "local_time"} when the action registry is empty.

Step 5: Use The Imported Spec Like Any Other

The result is Jidoka.Agent.Spec. Plan it, preflight it, run it, host it under Jido - everything that works for a DSL agent works here.

{:ok, plan} = Jidoka.plan(spec)
{:ok, preflight} = Jidoka.preflight(spec, "ping")

{:ok, answer} = Jidoka.chat(spec, "ping")

Step 6: Round-Trip With Inspect/Project

To compare authoring paths, lower both to inspection data:

imported = Jidoka.inspect(spec)
dsl = Jidoka.inspect(MyApp.SupportAgent)

imported.spec.operations == dsl.spec.operations
#=> true (when the document and DSL declare the same tools)

This is exactly how the golden tests assert DSL/import parity.

Common Patterns

  • Treat documents as data. Keep YAML alongside the application code, load it at boot, and pass the registries from a single trusted module.
  • Use one registry map per environment. Production registries can include modules dev does not; the documents stay the same.
  • Lean on the version field. Pin version: 1; reject anything else rather than silently accepting unknown shapes.
  • Compose with metadata. The document's free-form metadata block flows through to Spec.metadata and is visible from Jidoka.inspect/1 - useful for owner tagging and feature flags.
  • Prefer atom and string interchangeability in registries. the import registry matches :foo against "foo" either direction so you can use whichever feels natural per environment.

Testing

A good import test exercises three behaviors: parsing, ref resolution, and parity with the equivalent DSL.

defmodule MyApp.ImportTest do
  use ExUnit.Case, async: true

  @yaml """
  version: 1
  agent:
    id: time_agent
    model: openai:gpt-4o-mini
    instructions: Call local_time when asked for the time.
  tools:
    actions:
      - local_time
  """

  test "compiles a YAML document into a usable spec" do
    {:ok, spec} =
      Jidoka.import(@yaml,
        actions: %{"local_time" => MyApp.Tools.LocalTime}
      )

    assert spec.id == "time_agent"
    assert [%Jidoka.Agent.Spec.Operation{name: "local_time"}] = spec.operations

    llm = fn _intent, journal ->
      case map_size(journal.results) do
        0 -> {:ok, %{type: :operation, name: "local_time", arguments: %{}}}
        _ -> {:ok, %{type: :final, content: "09:30"}}
      end
    end

    assert {:ok, "09:30"} = Jidoka.chat(spec, "now?", llm: llm)
  end

  test "missing action ref returns a typed validation error" do
    assert {:error, %Jidoka.Error.Invalid{} = error} =
             Jidoka.import(@yaml, actions: %{})

    assert %{details: %{reason: {:unknown_registry_ref, :actions, "local_time"}}} =
             Jidoka.error_to_map(error)
  end

  test "DSL and import compile to the same operations" do
    {:ok, spec} =
      Jidoka.import(@yaml,
        actions: %{"local_time" => MyApp.Tools.LocalTime}
      )

    dsl_operations = MyApp.TimeAgent.spec().operations
    assert Enum.map(spec.operations, & &1.name) ==
             Enum.map(dsl_operations, & &1.name)
  end
end

Troubleshooting

SymptomLikely CauseFix
{:error, %Jidoka.Error.Invalid{}} with reason {:unknown_registry_ref, ...}A ref in the document was not in the matching registry.Add the ref or remove it from the document; ref keys are case sensitive.
{:error, {:unsupported_import_document_version, ...}}The document version is not 1.Bump or downgrade the document to match.
{:error, {:unsupported_import_format, _}}format: was set to something other than :json or :yaml.Pass :json or :yaml, or remove the option to use detection.
JSON decodes but agent is missingThe document was top-level keys without an agent: block.Jidoka.Import normalizes well-known top-level agent keys, but unfamiliar ones are dropped. Wrap them in agent:.
Control fires for the wrong operationThe when: clause used a key that is not in the operation metadata.Inspect with Jidoka.inspect(spec).spec.operations to confirm metadata keys; the DSL and importer use the same keys.

Reference