policy And protect
Actions can be staged by deterministic DSL handlers or by an optional planner mounted on the Agent. Either way, dangerous actions should not execute just because text matched or a model proposed a tool instruction.
protect connects an action to a policy. In real agents, keep this next to the
action module with the block form:
actions MyApp.SupportActions do
protect(:delete_account, with: :delete_account_confirmation)
endThe policy is a tiny deterministic router that is used only while that action effect is waiting:
policy :delete_account_confirmation do
request(:confirm_delete_account)
accept(:confirmed_delete, regex: ~r/^yes, delete it$/i)
reject(:cancel_delete, regex: ~r/^no|cancel$/i)
otherwise(ask: :confirm_delete_account_retry)
attempts(3, then: :cancel_pending)
endBy default, while a policy is active the next user turn bypasses the normal
agent router. That matters: a short answer like "yes" should approve the open
policy awaitable, not accidentally route to some generic conversation intent.
This is the resolver: :conversation mode and remains the default.
Approved actions still do not run automatically inside normal routing. A matching
policy response produces an :approved effect and the runtime persists that
state before returning:
{:ok, approved} =
Spectre.ask(MyApp.SupportAgent, "yes, delete it", state: staged.state)
[%Spectre.Effect{status: :approved}] = approved.state.pending_effects
{:ok, executed} =
Spectre.execute(approved.state, %{agent: MyApp.SupportAgent})A host may already know that a policy is satisfied—for example, terms were accepted in account settings or another channel. Resolve the policy through its declared label instead of synthesizing a user reply:
{:ok, approved} =
Spectre.resolve_policy(
MyApp.SupportAgent,
staged,
{:accept, :confirmed_delete},
conversation_id: conversation.id,
assigns: %{user: user}
)The state adapter is called before this function returns. The result contains
the accepted awaitable, the :approved effect, and a
:policy_resolved audit event with source: :host. An unknown label is
rejected, so external resolution cannot bypass the policy declaration. With a
live session, the session state advances in the same operation.
Approval outside the conversation
Use an externally addressed policy when the customer requests an action but a trusted host actor, such as an administrator, must approve it:
actions MyApp.SupportActions do
protect(:issue_refund, with: :refund_approval, resolver: :external)
end
approval_pending_reply(:approval_pending)
policy :refund_approval do
# These labels are the closed vocabulary accepted from the host. Customer
# text never reaches these matchers while the resolver is external.
accept(:refund_approved, regex: ~r/^approved$/i)
reject(:refund_rejected, regex: ~r/^rejected$/i)
endThe awaitable opens normally, but subsequent customer messages continue
through handlers and routing. They never enter the policy matcher and never
increment its attempt counter, so text such as "yes" cannot self-approve the
action. Resolve the exact current gate by id:
awaitable = Spectre.Result.open_awaitable(staged)
{:ok, approved} =
Spectre.resolve_policy(
session_or_instance,
{:awaitable, awaitable.id},
{:accept, :refund_approved},
assigns: %{admin_id: admin.id}
)For an Instance, the transport-safe turn.boundary.request.id is accepted as
the addressed id as well. Resolution loads or rebases onto current state, so
normal turns may advance revisions after the request. A missing, closed,
expired, or already-resolved id returns a typed error without mutation. An
external awaitable accepts only a %Spectre.Policy.Resolution{source: :host};
the compact tuple form constructs that trusted source. Journal policy records
include both source and resolver.
Only one protected approval may be pending in this first version. A Session
cannot stage another effect while the waiting effect remains, so protected and
unprotected action attempts both produce the typed :approval_pending
outcome without staging anything. Configure approval_pending_reply/2 to turn
it into a normal customer-facing reply. Instance Runs remain isolated: an
unprotected action in another Run may proceed, but a protected action is
rejected globally and cannot open a second gate. Expiration and
Spectre.cancel/2 retain their existing cancellation semantics.
Complete Session flow
The application should persist the awaitable id in its admin inbox together with its own tenant, customer, and authorization context. Do not expose the host resolution endpoint as a customer-callable API: Spectre verifies the current gate, source, and declared label, while authentication and the decision to grant an administrator authority remain host responsibilities.
{:ok, session} =
Spectre.Session.start_link(
agent: MyApp.SupportAgent,
conversation_id: "customer-42"
)
# 1. The customer requests the protected action.
{:ok, opening_turn} = Spectre.turn(session, "refund order 481")
%Spectre.Awaitable{
id: approval_id,
resolver: :external,
status: :open,
attempts: 0
} = Spectre.Result.open_awaitable(opening_turn.result)
MyApp.ApprovalInbox.insert!(%{
id: approval_id,
customer_id: "customer-42",
kind: :refund
})
# 2. Ordinary customer conversation continues. Even text matching an accept
# regex cannot resolve the external gate or increment its attempts.
{:ok, _ordinary_turn} = Spectre.turn(session, "yes — when will it arrive?")
%Spectre.Awaitable{status: :open, attempts: 0} =
session
|> Spectre.state()
|> Spectre.State.open_policy_awaitable()
# 3. An authenticated admin resolves the exact current id. Use an explicit
# Resolution when the audit event should carry application-owned metadata.
{:ok, resolution} =
Spectre.Policy.Resolution.new(
:accept,
:refund_approved,
:host,
%{approval_ticket: "approval-9001"}
)
{:ok, approved} =
Spectre.resolve_policy(
session,
{:awaitable, approval_id},
resolution,
assigns: %{admin_id: "admin-7"}
)
%Spectre.Effect{status: :approved} = Spectre.Result.pending_effect(approved)
# 4. Approval and execution remain separate durable boundaries.
{:ok, completed} = Spectre.execute(session, approved)
{:ok, refund_result} = Spectre.Result.action_outcome(completed)The compact {:accept, :refund_approved} form is equivalent to a resolution
with source: :host but carries no resolution metadata. To reject, construct
:reject, :refund_rejected, :host; the effect is cancelled and must not be
executed. A missing id, a stale/closed/expired id, a duplicate resolution, a
user-sourced resolution, or an undeclared label returns a typed error and
leaves current state unchanged.
Spectre.execute/3 rejects :waiting_policy effects. It also injects
:effect_id and :idempotency_key into ctx.opts, so application code can
deduplicate a retry at its durable side-effect boundary.
That boundary is intentional. It gives the host application a clear place to
control transactions, permissions, audit logs, delivery, and retries. Spectre
accepts one action effect per turn; multi-action chains belong in
spectre_directive rather than being partially executed by the conversation
runtime.
actions And Hooks
An action module is ordinary Elixir:
defmodule MyApp.SupportActions do
def delete_account(args, ctx) do
MyApp.Accounts.delete_user(ctx.assigns.user_id, args)
end
endDeclare it in the agent:
actions MyApp.SupportActions do
protect(:delete_account, with: :delete_account_confirmation)
after_action(:delete_account,
on: :delivered,
run: {MyApp.AuditLog, :record_action}
)
endThe bare form also exists for simple agents that only need to register an action module:
actions(MyApp.SupportActions)Hooks run after a completed action effect exists. They are useful for audit trails, notifications, and delivery bookkeeping.
before_action Guards
Guards are the pre-execution veto point. They run right before the capability is invoked — after routing, planning, and any policy approval — so they can stop an action because of host state no route or policy can see: an open draft, a role restriction, a quota.
before_action :create_project, run: {MyApp.Guards, :no_duplicate_draft}defmodule MyApp.Guards do
def no_duplicate_draft(_action, ctx) do
if MyApp.Projects.open_draft?(ctx.assigns.user_id) do
{:suppress, "You already have an open draft. Finish or cancel it first."}
else
:allow
end
end
endA guard receives (action, ctx) and returns:
:allow(or:ok) — execution proceeds{:suppress, reply_text}— the pending effect is cancelled without invoking the capability; the turn resolves as a normal reply carrying that text and an:effect_suppressedevent{:error, reason}— the effect fails closed
:all guards every action. Guards run in declaration order and the first
non-:allow outcome wins. They are Agent-owned infrastructure: Skills cannot
declare them. An invalid guard reply fails the effect instead of allowing it.
Generic Action Providers
action is provider-neutral. Spectre owns staging, policy, persistence,
idempotency, journal events, and terminal outcomes; a registered provider owns
discovery and execution.
The existing actions MyApp.SupportActions declaration is compatibility
shorthand for the built-in :local provider and its map/context callback
convention. It does not select Kinetic. Companion libraries mount their own
providers on demand.
Provider authors can use the low-level port directly:
defmodule MyApp.GitHubProvider do
@behaviour Spectre.Action.Provider
def actions(_opts) do
[
Spectre.Action.Spec.new(
name: :create_issue,
via: {:mcp, :github},
description: "Creates a GitHub issue",
mode: :write,
schema: %{
type: "object",
required: ["title"],
properties: %{
"title" => %{type: "string"},
"body" => %{type: "string"}
}
}
)
]
end
def execute(%Spectre.Action{name: :create_issue, args: args}, ctx, opts) do
MyApp.GitHubMCP.create_issue(args, ctx, opts)
end
endMount and use it without changing the engine:
defmodule MyApp.ProjectAgent do
use Spectre.Agent
action_provider({:mcp, :github}, MyApp.GitHubProvider)
protect({:mcp, :github, :create_issue},
with: :confirm_issue_creation
)
flow :github do
on :CREATE_ISSUE, regex: ~r/\bcreate.*\bissue\b/i do
action({:mcp, :github, :create_issue},
args: %{title: "Bug report"}
)
end
end
endA planner returns %Spectre.Action{via: ..., name: ..., args: ...}. It never
selects an implementation module directly: dispatch resolves via against the
providers compiled into the Agent. When a planned action carries a schema hash,
Spectre verifies the provider still exposes that schema before execution.
When schema declares a JSON-Schema validation keyword, Spectre validates both
the schema and the proposed arguments before staging and again at provider
execution. The closed subset covers primitive/object/array types, properties,
required/additional properties, enum/const, numeric and length bounds,
patterns, and bounded allOf/anyOf/oneOf/not. Unsupported constraint
keywords fail closed; discovery-only maps such as %{arity: 2, version: 1}
remain metadata for compatibility. Validation errors contain paths and rule
names, never rejected values.
Schema validation establishes shape, not authority. Providers must still authenticate the caller, authorize the target resource, enforce tenancy and make the real side effect idempotent.
Optional libraries should register providers or a planner through
Spectre.Extension. The public composition stays:
use Spectre.Agent
use Spectre.Kinetic, actions: MyApp.ActionsThere is no alternate Agent engine and no use Spectre facade.
SpectreKinetic Planner
spectre_kinetic is an on-demand implementation of the action planner port.
Spectre does not detect or call it implicitly.
When an Agent that mounts Spectre.Kinetic receives an LLM reply, the Kinetic
adapter scans visible text and AL blocks. For example, a model might return:
I can create that project brief.
<al>
CREATE PROJECT title="Marketplace MVP"
</al>Spectre keeps the visible text for the user and delegates the AL block to
Kinetic for tool selection, slot mapping, and planning. The result is a
%Spectre.Effect{kind: :action}. If the action is protected, Spectre opens a
%Spectre.Awaitable{kind: :policy} before anything executes.
Define application actions with Kinetic's existing code-first DSL:
defmodule MyApp.ProjectActions do
use SpectreKinetic
@al ~s(CREATE PROJECT WITH: TITLE="Marketplace MVP")
@doc "Creates a project"
@spec create_project(String.t()) :: {:ok, term()} | {:error, term()}
def create_project(title), do: MyApp.Projects.create(%{title: title})
endMount that module through Kinetic on the Agent. This one use registers both
the Kinetic planner and its built-in action provider; the application does not
implement an adapter:
defmodule MyApp.ProjectAgent do
use Spectre.Agent, prompt_root: "priv/agents/project/prompts"
use Spectre.Kinetic,
actions: MyApp.ProjectActions,
modes: [create_project: :write]
model(MyApp.LLM)
protect({:kinetic, :create_project}, with: :terms)
policy :terms do
request(:accept_terms)
accept(:accepted_terms, regex: ~r/^yes$/i)
reject(:rejected_terms, regex: ~r/^no$/i)
end
flow :project do
on :CREATE_PROJECT, regex: ~r/\b(create|start).*\bproject\b/i do
act(:create_project)
end
end
endSpectre.Kinetic.Actions is the provider implementation supplied by the
companion package. It is mounted internally by use Spectre.Kinetic; do not
write actions Spectre.Kinetic.Actions in the Agent.
When MCP, Lens, or another extension already supplies action providers, mount
Kinetic without :actions:
use Spectre.Agent
use Spectre.MCP, servers: [...]
use Spectre.KineticKinetic then plans against the providers registered by those extensions. If no
:actions module and no other providers are mounted, the planner has no
actions to select.
Kinetic can load runtime data from:
- the
runtime:option passed touse Spectre.Kinetic :spectre_kinetic, :runtime:spectre_kinetic, :compiled_registry:spectre_kinetic, :registry_jsonSPECTRE_KINETIC_COMPILED_REGISTRYSPECTRE_KINETIC_REGISTRY_JSON- action specs exposed by the Agent's registered providers
Before planning, Kinetic verifies that a borrowed or precompiled registry matches exactly the providers mounted on that Agent. Missing, changed, or unmounted registry entries are rejected.
Spectre does not maintain a second AL parser or registry. Kinetic owns AL extraction, registry loading, tool selection, slot mapping, reranking, and planning classifiers. Spectre owns providers, conversation routing, policy gates, state, effects, awaitables, execution, and journal boundaries.