# Import (JSON/YAML)
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](getting-started.md)).
- Familiarity with the operation contract from
[Tools And Operations](tools-and-operations.md).
- 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.
```bash
mix deps.get
mix test
```
## Quick Example
The smallest portable agent is one YAML document plus an `actions` registry.
```elixir
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.
```diagram
╭───────────────────╮ ╭──────────────────────╮ ╭─────────────────╮
│ 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.
```elixir
{: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.
```yaml
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.
```elixir
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.
```elixir
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.
```elixir
{: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:
```elixir
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.
```elixir
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
| Symptom | Likely Cause | Fix |
| --- | --- | --- |
| `{: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 missing | The 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 operation | The `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
- [`Jidoka`](`Jidoka`) - public facade: `Jidoka.import/2`.
- [`Jidoka.Import`](`Jidoka.Import`) - compiler and registry option
handling: `import/2`, `import!/2`, `load/2`, `load!/2`.
- [`Jidoka.Import.AgentDocument`](`Jidoka.Import.AgentDocument`) - the
validated document schema and version constant.
- Import registry - registry fetch
semantics used to resolve refs.
- [`Jidoka.Agent.Spec`](`Jidoka.Agent.Spec`) - the spec shape both DSL and
import compile into.
- [`Jidoka.Agent.Spec.Operation`](`Jidoka.Agent.Spec.Operation`) - the
operation shape used for both authoring paths.
## Related Guides
- [Agent DSL](agent-dsl.md) - the Elixir-native authoring path and its
parity with the importer.
- [Tools And Operations](tools-and-operations.md) - operation contract
documents map to.
- [Inspection And Preflight](inspection-and-preflight.md) - comparing
imported and DSL specs through inspection.
- [Testing And Evals](testing-and-evals.md) - using imported specs in
deterministic eval cases.