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)
endWhile 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.
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.
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.
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.