This guide describes the package surface intended for application code. Cantrip keeps the original vocabulary deliberately: a cantrip is a reusable value, an entity is the running process or episode it produces, a circle is the configured environment, and the loom is the durable turn tree.
Common Workflows
The public API is organized around five distinct workflows:
- Workspace cantrip - assemble an LLM, identity, medium, gates, wards, and
loom storage with
Cantrip.new/1, then run it withCantrip.cast/3. - Persistent entity - keep a supervised process alive across related
prompts with
Cantrip.summon/1andCantrip.send/3. - Child composition - delegate work to specialized cantrips with
Cantrip.cast/3orCantrip.cast_batch/2. - Familiar coordinator - launch
Cantrip.Familiarwhen you want the packaged codebase-facing circle instead of assembling workspace gates, code-medium reasoning, storage, and delegation yourself. - Runtime integration - stream events, persist looms, run Mix tasks, or expose ACP without changing the cantrip shape.
Public Modules
These modules are the package surface documented by ExDoc and treated as stable for application code:
Cantrip- construct, cast, batch-cast, summon, send, stream, and fork cantrips.Cantrip.Familiar- build the packaged codebase-facing coordinator.Cantrip.Familiar.Eval- run Familiar eval scenarios from application code.Cantrip.LLM- implement or configure LLM adapters.Cantrip.LLM.Response- construct normalized responses from custom adapters.Cantrip.FakeLLM- script deterministic LLM responses in tests and evals.Cantrip.Circle- construct circle configuration data.Cantrip.Identity- construct identity and model-facing option data.Cantrip.Medium- implement custom medium modules.Cantrip.WardPolicy- inspect and compose ward policy data.Cantrip.Loom- inspect, persist, fork, and annotate loom records.Cantrip.Loom.Storage- implement custom loom storage backends.Cantrip.Cluster- connect and replicate Mnesia-backed loom tables on explicit BEAM clusters.Cantrip.ACP.Server- run the packaged stdio ACP entrypoint.Cantrip.ACP.Diagnostics- inspect live ACP sessions and bridges from remsh during operations.Mix.Tasks.Cantrip.Cast,Mix.Tasks.Cantrip.Familiar, andMix.Tasks.Cantrip.Eval- command-line entrypoints shipped with the package.
Other modules under lib/ are implementation details. They can remain callable
inside the package, tests, or advanced local debugging, but they are hidden from
ExDoc so refactors do not become public API breakage.
Build a Cantrip
{:ok, llm} = Cantrip.LLM.from_env()
{:ok, cantrip} =
Cantrip.new(
llm: llm,
identity: %{system_prompt: "Call done with the final answer."},
circle: %{type: :conversation, gates: [:done], wards: [%{max_turns: 8}]}
)Cantrip.new/1 accepts keyword lists or maps and returns a reusable cantrip
value. The important fields are:
:llm-{module, state}implementingCantrip.LLM.:identity- system prompt and model-facing identity options.:circle- medium, gates, and wards.:loom_storage-:memory,{:jsonl, path}, or{:mnesia, opts}.:child_llm- optional cheaper or specialized LLM inherited by child cantrips.:retry- provider retry policy.:folding- prompt-context folding options.
Run One Episode
{:ok, result, next_cantrip, loom, meta} =
Cantrip.cast(cantrip, "Summarize this incident report.")result is the value returned by done. next_cantrip carries reusable
runtime configuration, loom is the durable turn tree, and meta describes
termination or truncation.
Use Cantrip.cast_stream/2 when consumers need runtime events while the
episode is executing.
Keep an Entity Alive
{:ok, pid} = Cantrip.summon(cantrip)
{:ok, first, _next, _loom, _meta} = Cantrip.send(pid, "Load the dataset.")
{:ok, second, _next, _loom, _meta} = Cantrip.send(pid, "Analyze the dataset.")Persistent entities are supervised processes. They keep process-owned state across sends. In the code medium, bindings and message history remain available to later episodes.
Compose Work
Composition uses the same public API from inside or outside the code medium.
Outside a parent code-medium turn, pass an llm explicitly. Inside a parent
turn, children can inherit the parent context's child LLM.
{:ok, child} =
Cantrip.new(
llm: llm,
identity: %{system_prompt: "Read the material and return a compact summary."},
circle: %{type: :conversation, gates: [:done], wards: [%{max_turns: 5}]}
)
{:ok, summary, _child, _loom, _meta} =
Cantrip.cast(child, document_text)For fan-out:
{:ok, summaries, _children, _looms, _meta} =
Cantrip.cast_batch([
%{cantrip: child, intent: "Summarize chapter one."},
%{cantrip: child, intent: "Summarize chapter two."}
])When called from a parent code-medium turn, child results are returned upward
and child turns are grafted into the parent loom. The parent circle still
applies: casting a pre-built child checks the parent's max_depth before the
child starts, and the child runs with wards composed from parent and child
circles. Numeric wards such as max_turns and max_depth tighten with min;
boolean wards such as require_done_tool tighten with or. cast_batch uses
the same child-cast path for each item and is bounded by the parent's
max_concurrent_children ward.
Parent circles can also declare what children are allowed to exist or run:
wards: [
%{max_depth: 2},
%{child_medium_allowlist: [:conversation]},
%{child_gate_allowlist: [:done, :read_file]},
%{child_max_turns_ceiling: 5},
%{max_children_total: 10}
]These declaration-time child wards are checked before runtime composition.
Allow/deny lists constrain the child circle. Child turn/depth ceilings require
the child to declare max_turns / max_depth at or below the ceiling; Cantrip
does not silently rewrite a nonconforming child. max_children_total is a
cumulative accepted-cast budget for the parent code-medium entity. Rejected
child construction returns {:error, reason}; rejected child casts return
{:error, reason, child} and are recorded as error observations in the parent
loom when called from a parent turn.
Choose a Medium
Conversation medium:
circle: %{type: :conversation, gates: [:done], wards: [%{max_turns: 5}]}Code medium:
circle: %{
type: :code,
gates: [:done, :read_file],
wards: [%{max_turns: 10}, %{sandbox: :port}]
}Bash medium:
circle: %{
type: :bash,
gates: [:done, :read_file],
wards: [
%{max_turns: 5},
%{bash_writable_paths: ["tmp/cantrip-output"]},
%{bash_network: :off}
]
}Bash requires an OS sandbox. Cantrip detects bubblewrap on Linux and
sandbox-exec on macOS; if no sandbox is available, bash cantrips fail at
construction rather than falling back to ambient shell authority. Tests can use
medium_opts: %{sandbox: :passthrough}, but production cannot.
Plain code-medium circles default to the port sandbox when no sandbox ward is
present. %{sandbox: :port} makes that boundary explicit. It evaluates
Dune-restricted Elixir in a child BEAM process while gates, child cantrip API
calls, stdio, and hot-loading are resolved through the parent runtime.
Child-origin atoms that are not part of Cantrip's wire vocabulary cross this
boundary as strings, so hot-loaded child code cannot force new atoms into the
parent BEAM.
The Familiar is different: Cantrip.Familiar.new/1 defaults to
sandbox: :unrestricted for trusted operator-local coding work so its prompt's
native introspection affordances (binding/0, Code.fetch_docs/1) are true.
Use Cantrip.Familiar.new(sandbox: :port, port_runner: [...]) when you also
want deployment-level OS/container controls; passing port_runner: [...]
without an explicit sandbox selects :port so the runner is used.
sandbox: :port_unrestricted keeps the child process but evaluates raw Elixir
there. sandbox: :dune is available when in-process restrictions are the
right tradeoff — it is a deliberately smaller-surface variant of the code
medium (see docs/port-isolated-runtime.md "Dune Variant"); entity prompts
need to match that surface.
Configure Gates and Wards
Built-in gates are done, echo, read_file, list_dir, search, mix,
and compile_and_load. Filesystem and Mix gates require root dependencies in
production contexts; the Familiar wires these from its :root option. The
Familiar only includes compile_and_load when constructed with evolve: true.
Wards are maps. Common wards include:
%{max_turns: n}%{allow_mix_tasks: ["compile", "format"]}%{mix_timeout_ms: 60_000}%{mix_max_output_bytes: 50_000}%{max_depth: n}%{port_runner: [executable, arg1, ...]}%{max_concurrent_children: n}%{max_children_total: n}%{child_medium_allowlist: mediums}%{child_gate_allowlist: gates}%{child_gate_denylist: gates}%{child_max_turns_ceiling: n}%{child_max_depth_ceiling: n}%{code_eval_timeout_ms: n}%{allow_compile_modules: modules}%{allow_compile_paths: paths}%{allow_compile_signers: signers}
compile_and_load accepts exact module allowlists via allow_compile_modules.
Deprecated allow_compile_namespaces wards are rejected loudly, and framework
module names are not hot-loadable.
Gate failures are observations. They are returned to the entity as data so the next turn can adapt.
Persist the Loom
base = [
llm: llm,
identity: %{system_prompt: "Call done with the final answer."},
circle: %{type: :conversation, gates: [:done], wards: [%{max_turns: 8}]}
]
Cantrip.new(Keyword.put(base, :loom_storage, :memory))
Cantrip.new(Keyword.put(base, :loom_storage, {:jsonl, "loom.jsonl"}))
Cantrip.new(Keyword.put(base, :loom_storage, {:mnesia, table: :cantrip_turns}))Use JSONL for portable traces and Mnesia for BEAM-native durable workspace state. Folding changes prompt context only; it does not delete loom records.