use Spectre.Agent

use Spectre.Agent,
  stack: MyApp.AI,
  prompt_root: "priv/agents/support/prompts",
  history: 20,
  shutdown: :timer.minutes(10)

prompt_root tells Spectre where prompt templates live. If you do not set it, Spectre uses priv/spectre/prompts.

use Spectre.Agent also accepts normal config keys. The built-in ones are:

  • :prompt_root
  • :stack
  • :shutdown
  • :history
  • :fail
  • :arbitrator
  • :journal
  • :turn_handlers

Unknown keys are kept in __spectre_config__/0, so host applications can attach their own metadata. :arbitrator is copied into router config; the other keys stay in runtime config.

:stack is a logical module reference to a Spectre.Stack. Defining an Agent does not load or validate that module; it is resolved only when the Stack is used. The binding does not automatically expose installed Operations or Actions. Skills inherit this reference and cannot configure their own Stack.

model

model(MyApp.LLM)
model(MyApp.LLM, with: :chat, temperature: 0)

By default Spectre calls MyApp.LLM.complete(prompt, opts). Use with: or function: if your adapter exposes a different function.

Minimal adapter:

defmodule MyApp.LLM do
  def complete(prompt, opts) do
    MyApp.OpenAI.complete(prompt, opts)
  end
end

model/2 stores {module, function, opts} under the runtime :model option. Those opts are merged into every ask call. fallback: is special: if the primary model returns {:error, reason}, Spectre calls the fallback model with primary_error: reason. llm_timeout: controls Spectre's isolated completion deadline and defaults to 60 seconds.

classifier

# Main model: used for normal `ask/2` responses.
model(MyApp.MainLLM, model: "large")

# Classifier model: used only when `:llm_classifier` arbitrates routing.
classifier MyApp.SmallLLM,
  model: "small",
  fallback: MyApp.SmallFallbackLLM,
  prompt: &MyApp.ClassifierPrompt.build/1,
  llm_opts: [temperature: 0.0, max_tokens: 8, llm_timeout: 12_000],

  # Optional local classifier used by the `:classifier` router strategy.
  local: MyApp.LocalClassifier,
  artifact_dir: "priv/spectre/support",
  local_classifier_timeout: 2_000

The first argument configures the LLM adapter used only by :llm_classifier arbitration. If it is not configured, the LLM classifier falls back to model/2. prompt: customizes the classifier prompt, and llm_opts: are merged with these defaults:

[purpose: :classifier, temperature: 0.0, max_tokens: 8]

local: configures the adapter used by the :classifier router strategy. Local classifier runtime overrides use classifier_local:. local_classifier_timeout: controls the local adapter deadline.

The built-in arbitrator uses an enabled :llm_classifier after local and other cheap evidence cannot make a decision. A custom prompt: callback receives at least text, labels, recent_chat, and structured evidence; router calls also add input, state, candidates, and local_result assigns. The model must return exactly one configured label.

Timeouts, crashes, and malformed provider replies use the shared Spectre.Provider.Failure contract. See Provider Resilience.

journal

journal MyApp.SpectreJournal,
  events: [:routing],
  mode: :async,
  on_error: :warn,
  include_input: false,
  sample_rate: 1.0,
  buffer_size: 1_000,
  overflow: :drop_newest

journal/2 stores an opt-in {Store, opts} configuration. The store implements Spectre.Journal.Store.append/2. Routing records exclude input and reply content by default and are delivered through a supervised bounded buffer.

Use journal(false) to disable an application-level default. Use mode: :sync, on_error: :error only when an append must succeed before the turn continues. See Journal for the record schema, privacy model, sampling, and delivery semantics.

Runtime Boundaries

These macros configure runtime adapters:

state(MyApp.AgentStateStore)
memory(MyApp.AgentMemory)
embedding(MyApp.Embeddings, model: "intfloat/multilingual-e5-small")

actions MyApp.SupportActions, namespace: :support do
  protect(:delete_account, with: :delete_account_confirmation)
end

action_provider({:mcp, :github}, MyApp.GitHubProvider)

shutdown(:timer.minutes(10))
idle(:timer.minutes(5))
history(50)
fail(:agent_failure_reply, locale: :en)

What they mean:

  • state/1 module may implement load/3, load/2, persist/4, or persist/2.
  • memory/1 module may implement recall/2, then remember/4, persist/4, remember/2, or persist/2.
  • embedding/2 provides the adapter used by embedding routing.
  • classifier/2 provides classifier-specific LLM and local adapters.
  • journal/2 configures structured decision recording.
  • turn_handler/2 appends an optional owner of a complete normal turn.
  • actions/2 mounts an ordinary Elixir module as the built-in :local action provider.
  • action_provider/3 mounts a provider under a stable atom, string, or namespaced tuple.
  • action_planner/2 sets the provider-neutral planner port. Companion packages normally register it through their own use Spectre.* DSL.
  • shutdown/1 and idle/1 affect supervised sessions.
  • history/1 controls chat history stored under state.data.chat_history. The runtime appends one entry per completed turn; the host never maintains the window itself. With history 50, summary: {M, :f} the turns evicted from the window are folded into a rolling summary under state.data.chat_summary — the summarizer receives (current_summary_or_nil, evicted_entries) and returns the new summary string; on error the previous summary is kept. The summary is also shown to the LLM fallback classifier as conversation context.
  • before_action/2 registers a pre-execution guard that can veto an action with a reply ({:suppress, text}) based on host state. See Actions.
  • fail/2 configures monitor fallback prompt rendering.

Per-call options such as state: %Spectre.State{} or memory: value can bypass adapters for tests or host-controlled replay.

Companion libraries extend the same Agent after its core DSL is initialized:

defmodule MyApp.Agent do
  use Spectre.Agent
  use Spectre.Kinetic,
    actions: MyApp.Actions,
    top_k: 5
end

use Spectre.Agent remains the only Agent entry point. Each companion package registers its provider, planner, or other extension port on demand.

turn_handler and turn_handlers

turn_handler MyApp.ActiveWorkflow, namespace: :support

turn_handlers [
  {MyApp.ActiveWorkflow, namespace: :support}
]

Handlers execute in declaration order after any already-open policy and before routing. Each implements Spectre.Turn.Handler and returns :cont or {:reply, %Spectre.Turn.Handler.Reply{}}. The first reply owns the turn. Failures and timeouts stop the turn rather than allowing another route to reinterpret the input.

turn_handlers/1 replaces the full list. Pass false to disable it in an Agent or in trusted per-call options. Skills cannot configure this Agent-wide infrastructure. A handler is a pre-route ownership hook, not the canonical host result or a protocol envelope. Use the narrower input, memory, Skill, action, journal, or transport boundary when an integration does not own the complete turn. See Turn semantics and integration boundaries.

flow And on

flow groups related routes. on declares one route:

flow :sales do
  on :QUOTE_REQUEST,
    regex: ~r/\b(quote|estimate|proposal)\b/i,
    embedding: ["can you estimate this project?", "send me a proposal"],
    learn: true do
    reason(:quote_request)
  end
end

Flows nest. A nested flow is a taxonomy grouping, not a separate routing pass: all rules stay in one flat candidate list, and every rule keeps the full path of the flows it was declared in.

flow :checkout do
  on :PAY_CARD, embedding: ["pay by card"] do
    act(:pay_card)
  end

  flow :shipping do
    on :TRACK_PARCEL, embedding: ["where is my parcel?"] do
      reason(:track_parcel)
    end
  end
end

Nesting affects three things:

  • The compiled rule stores flow_path (here [:checkout, :shipping] for :TRACK_PARCEL) while flow stays the innermost name (:shipping).
  • state.current_flow matches by membership in flow_path, so current_flow: :checkout prioritizes the whole :checkout subtree while current_flow: :shipping prioritizes only that branch.
  • The LLM fallback classifier receives the labels grouped by flow path as an indented taxonomy instead of a flat list, which helps it discriminate sibling intents. See Routing.

Route labels stay globally unique across all flows; nesting changes grouping, never names. inject declarations and flow options are inherited by nested flows (a nested flow's own options win on conflict).

A route can include:

  • regex: one regex or a list of regexes
  • bag: simple phrase examples for bag-distance routing
  • jaro: phrase examples for Jaro string similarity
  • embedding: semantic examples compared with vectors
  • cache: false excludes this route from semantic-cache rows and lookup
  • learn: true lets accepted LLM fallback classifications add online examples
  • check: or checks: metadata guards such as language or role
  • via: per-route strategy visibility
  • custom options kept on the compiled rule

Routes are evaluated in this order:

  1. interrupts
  2. rules from the current flow, if state.current_flow is set
  3. every other rule

Every router strategy also calls the same visibility helper: a rule with no via: is visible to all strategies; a rule with via: [:classifier] is visible only to classifier plugs. check: and checks: are applied before a strategy can see the rule.

Checks read from input.text or input.meta:

on :ITALIAN_INFO,
  regex: ~r/^info$/i,
  check: {:language, ["it", "italian"]} do
  reply(:platform_info_it)
end

Input plugs are the usual place to add that metadata.

Handlers

Spectre has seven route handlers:

reason(:prompt_name)
act(:prompt_name)
reply(:prompt_name)
run(:local_function)
work(:work_controller)
action(:dangerous_or_external_action)
ask(:prompt_name)

reason renders a prompt and calls the model without permitting action planning. Use it when the route should only think or answer.

act renders a prompt, calls the model, and lets the mounted planner stage provider-neutral actions from the closed catalog. Use it when the model may choose among explicitly permitted operations.

work starts a separate precise Work owned by the current Agent Instance and lets the Turn finish while the Work advances on the operational runtime.

ask is the legacy 0.1.x verb: it behaves like act when a planner is mounted and like reason otherwise. It remains supported for compatibility, but new Flows should prefer the unambiguous reason/act split; ask will be deprecated in a future release (see the migration guide).

reply renders a deterministic response without calling the model. This is good for help, health checks, canned answers, and policy confirmations.

run calls a function on the agent module:

def cancel_current(input, ctx), do: Spectre.cancel(input, ctx)

action stages an application action without calling the model:

on :DELETE_ACCOUNT, regex: ~r/^delete my account$/i do
  action(:delete_account) do
    reply(:delete_confirmation_started)
  end
end

Handlers also accept options:

on :PRICING, regex: ~r/\bprice\b/i do
  reply(:pricing, renderer: {MyApp.Replies, :route_reply}, key: :price)
end

on :SMART_TURN, learn: true do
  reason(:smart_turn_prompt, temperature: 0.2)
end

on :CREATE_PROJECT, regex: ~r/\bstart project\b/i do
  action(:create_project, args: %{source: "chat"}) do
    reply(:project_policy_started, renderer: {MyApp.Replies, :route_reply})
  end
end

reply can render a prompt file, or use a renderer. Renderers may be {Module, :function} or functions. Spectre supports arity 3 (prompt, input, ctx), arity 2 (prompt, assigns), and arity 1 (assigns).

action options become pending-action fields: via, args, mode, status, al, and hooks. A namespaced reference is also accepted directly:

action({:mcp, :github, :create_issue}, args: %{title: "Bug"})

If the action block includes reply, that reply is used for the policy request path without calling the model.

interrupt

Interrupts are global routes. They are checked before normal flow routes, so commands like help, cancel, handoff, or stop work even when a conversation is in the middle of a different flow.

interrupt :CANCEL, regex: ~r/\b(cancel|stop|nevermind)\b/i do
  run(:cancel_current)
end

Input Pipeline

The input pipeline runs before state loading, memory recall, policy matching, routing, prompt rendering, and action execution. It receives a normalized %Spectre.Input{text, meta, raw} and returns another %Spectre.Input{}.

Use it for things that should become true for the whole turn:

  • trim/case/unicode normalization
  • language detection
  • tenant, channel, role, or locale enrichment
  • mapping host payload fields into input.meta
  • early rejection of malformed input

Block form:

input_pipeline do
  plug(Spectre.Input.Plugs.NormalizeText,
    unicode: :nfc,
    case: :downcase,
    collapse_whitespace: true,
    trim: true
  )

  plug(MyApp.InputPlugs.Language)
  plug(MyApp.InputPlugs.AuthContext, required?: true)
end

List form:

input_pipeline([
  {Spectre.Input.Plugs.NormalizeText, [case: :downcase]},
  MyApp.InputPlugs.Language
])

An input plug implements Spectre.Input.Plug:

defmodule MyApp.InputPlugs.Language do
  @behaviour Spectre.Input.Plug

  def init(opts), do: opts

  def call(input, _context, _opts) do
    language = MyApp.Language.detect(input.text)
    {:cont, Spectre.Input.put_meta(input, :language, language)}
  end
end

Return values:

  • {:cont, input} continues the pipeline
  • {:halt, input} stops the pipeline and uses that input
  • {:error, reason} stops the turn with an error

The built-in Spectre.Input.Plugs.NormalizeText supports:

  • :trim default true
  • :collapse_whitespace default true
  • :case as :downcase, :upcase, or nil
  • :unicode as :nfc, :nfd, :nfkc, :nfkd, false, or nil

Prompt Files

With:

use Spectre.Agent, prompt_root: "priv/agents/support/prompts"

reason(:technical_support) resolves:

priv/agents/support/prompts/technical_support.text.heex

Policy prompts resolve under the policy name:

priv/agents/support/prompts/policies/delete_account_confirmation/confirm_delete_account.text.heex
priv/agents/support/prompts/policies/delete_account_confirmation/confirm_delete_account_retry.text.heex

Prompt templates receive assigns:

User message:
<%= @input.text %>

Conversation state:
<%= inspect(@state.data) %>

Memory:
<%= inspect(@memory) %>