Controls are Jidoka's policy layer. They are declared on Agent.Spec and run
while a turn is executing.
Use This When
- Use a control when input, operation, or output policy must run on every matching turn.
- Use tool-level
approval:when the only rule is human approval before an operation. - Keep business work in actions and workflows. A control decides whether work can continue; it does not perform the work.
Quick Example
The smallest control uses a built-in module to require public request context:
defmodule MyApp.SupportAgent do
use Jidoka.Agent
agent :support_agent do
instructions "Answer support questions clearly."
end
controls do
input Jidoka.Controls.RequireContext,
metadata: %{keys: [:tenant_id]}
end
end
llm = fn _intent, _journal, _context ->
{:ok, %{type: :final, content: "Ready."}}
end
{:ok, "Ready."} =
Jidoka.chat(MyApp.SupportAgent, "Help me",
context: %{tenant_id: "tenant-1"},
llm: llm
)Boundaries
Jidoka currently supports these control points:
inputruns before prompt assembly and the first model call.operationruns before a model-requested operation capability executes.outputruns after structured result validation and before the turn returns.max_turnsbounds model/operation loops.timeoutbounds wall-clock turn runtime in milliseconds.
Controls may return:
:cont,:allow, or:okto continue;{:block, reason}to fail deterministically;{:interrupt, reason}to pause when supported by that boundary;{:error, reason}to fail as a control error.
Operation interrupts are durable today. Input/output interrupts are currently reported as errors until those boundaries get resumable wait semantics.
Runtime Context
Controls receive their existing boundary-specific data and a Jidoka.Context
under :ctx. Jidoka.Context is the stable public shape for policy code:
defmodule MyApp.RequireTenant do
use Jidoka.Control, name: "require_tenant"
@impl true
def call(%{ctx: %Jidoka.Context{} = ctx}) do
case Jidoka.Context.fetch(ctx, :tenant_id) do
{:ok, _tenant_id} -> :cont
:error -> {:block, :missing_tenant}
end
end
endUse ctx.data for caller-supplied application context, ctx.arguments for
operation arguments, ctx.operation for the operation name, and
ctx.request_metadata for request metadata. Jidoka.Context.fetch/2 and
Jidoka.Context.get/3 match atom and string keys without creating atoms.
Input Controls
Input controls receive a map with the request, context, metadata, and input text:
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
endDeclare the control in the agent:
defmodule MyApp.SupportAgent do
use Jidoka.Agent
agent :support_agent do
instructions "Answer support questions tersely."
end
controls do
input MyApp.NoSecrets
end
endJidoka includes a few small controls for common cases:
controls do
input Jidoka.Controls.RequireContext,
metadata: %{keys: [:tenant_id]}
input Jidoka.Controls.MaxInputLength,
metadata: %{max: 8_000}
endOperation Controls And Approvals
Operation controls receive Jidoka.Runtime.Controls.OperationContext. This is
the safety boundary for tool/action execution.
For the common case where an operation simply needs human approval before it runs, prefer tool-level approval sugar:
tools do
action MyApp.RefundOrder,
idempotency: :unsafe_once,
approval: [
reason: :refund_requires_review,
message: "Review the refund before it is issued.",
ttl_ms: 300_000
]
endThis compiles to Jidoka's built-in approval control and still uses durable hibernate/resume.
Use an approval predicate when approval depends on operation arguments or request context, but the action should still use the standard approval flow:
defmodule MyApp.LargeRefundPredicate do
use Jidoka.ApprovalPredicate
@impl true
def call(%Jidoka.Context{} = ctx) do
amount = Map.get(ctx.arguments, "amount") || 0
tenant = Jidoka.Context.get(ctx, :tenant_id)
tenant == "enterprise" or amount >= 100
end
endAttach the predicate to the approval policy:
tools do
action MyApp.RefundOrder,
idempotency: :unsafe_once,
approval: [
when: MyApp.LargeRefundPredicate,
reason: :large_refund_review
]
endUse a custom operation control when the policy needs a different decision: tenant checks, external risk scoring, hard blocks, or custom interrupt reasons.
defmodule MyApp.RequireRefundApproval do
use Jidoka.Control, name: "require_refund_approval"
@impl true
def call(%Jidoka.Runtime.Controls.OperationContext{} = operation) do
if operation.operation == "refund_order" do
{:interrupt, :approval_required}
else
:cont
end
end
endAttach it to a specific operation:
controls do
operation MyApp.RequireRefundApproval,
when: [kind: :action, name: :refund_order],
metadata: %{queue: :refunds}
endOperation matches can be broad or narrow. Supported match keys are kind,
name, source, idempotency, and top-level metadata values:
controls do
operation MyApp.RequireRefundApproval,
when: [
kind: :tool,
source: :payments,
idempotency: :unsafe_once,
metadata: %{risk: "high"}
]
endIf an operation control interrupts, the turn hibernates:
{:hibernate, snapshot} =
Jidoka.turn(MyApp.RefundAgent, "Refund order_123")
review = snapshot.metadata["pending_review"]
approval = Jidoka.Review.Response.approve(review.interrupt_id)
{:ok, result} =
Jidoka.resume(snapshot, approval: approval)Operations marked :unsafe_once must have either an approval policy or a
matching operation control before the agent can compile into a plan. This makes
risky work visible during preflight instead of after a model chooses the
operation.
Request-level approval is available when the caller wants to review operations for one turn without changing the agent spec:
Jidoka.turn(MyApp.SupportAgent, "Refund A1001",
require_tool_approval: [only: ["refund_order"]]
)Output Controls
Output controls run after any configured structured result schema validates.
They receive both the assistant text and result_value:
defmodule MyApp.SafeReply do
use Jidoka.Control, name: "safe_reply"
@impl true
def call(%{result: text, result_value: value}) do
cond do
String.contains?(text, "forbidden") -> {:block, :unsafe_reply}
match?(%{approved: false}, value) -> {:block, :unapproved_result}
true -> :cont
end
end
endImport Shape
JSON/YAML controls use string refs resolved through registries:
controls:
max_turns: 8
timeout: 30000
inputs:
- control: no_secrets
operations:
- control: require_refund_approval
when:
kind: action
name: refund_order
outputs:
- control: safe_reply{:ok, spec} =
Jidoka.import(yaml,
registries: %{
controls: %{
"no_secrets" => MyApp.NoSecrets,
"require_refund_approval" => MyApp.RequireRefundApproval,
"safe_reply" => MyApp.SafeReply
}
}
)Testing
Use a fake LLM and local operation capability for deterministic control tests. Existing examples live under:
test/integration/controls_integration_test.exstest/integration/human_in_the_loop_integration_test.exstest/support/integration/controls/