Powered by AppSignal & Oban Pro

Cantrip Runtime Demo

notebooks/cantrip_demo.livemd

Cantrip Runtime Demo

This notebook is the runnable example grimoire for the package. It follows the same arc as the README: start with a cantrip value, cast an entity into a bounded circle, inspect the loom, then compose larger workflows through code, child cantrips, and the Familiar.

Install

Mix.install([
  {:cantrip, path: Path.join(__DIR__, "..")},
  {:kino, "~> 0.14"}
])
# Helper module for rendering loom turns. Defined once, used everywhere.

defmodule LoomViz do
  def table(loom, opts \\ []) do
    name = Keyword.get(opts, :name, "Loom")

    rows =
      loom.turns
      |> Enum.with_index(1)
      |> Enum.map(fn {turn, idx} ->
        content = get_in(turn, [:utterance, :content])
        observations = turn[:observation] || []

        gates = Enum.map_join(observations, ", ", & &1.gate)

        results =
          Enum.map_join(observations, " | ", fn obs ->
            prefix = if obs.is_error, do: "[ERR] ", else: ""
            result_str = if is_binary(obs.result), do: obs.result, else: inspect(obs.result)
            "#{prefix}#{obs.gate}: #{String.slice(result_str, 0, 60)}"
          end)

        %{
          "#" => idx,
          "Entity" => turn[:entity_id] || "—",
          "Content" => if(is_binary(content), do: String.slice(content, 0, 80), else: "—"),
          "Gates" => gates,
          "Results" => results,
          "Status" => cond do
            turn[:terminated] -> "terminated"
            turn[:truncated] -> "truncated"
            true -> "—"
          end
        }
      end)

    Kino.DataTable.new(rows, name: name)
  end
end

:ok

Setup

Copy .env.example to .env and fill in your API key. Cantrip.Application loads it on boot, so by the time you get here the environment is already configured.

# Verify the LLM is configured
{:ok, llm} = Cantrip.LLM.from_env()
provider = System.get_env("CANTRIP_LLM_PROVIDER", "openai_compatible")
model = System.get_env("CANTRIP_MODEL") || System.get_env("OPENAI_MODEL") || System.get_env("ANTHROPIC_MODEL") || System.get_env("GEMINI_MODEL")
IO.puts("Using #{provider} / #{model}")

new_cantrip = fn opts ->
  opts
  |> Keyword.put_new(:llm, llm)
  |> Cantrip.new()
end

:ok

What is Cantrip?

A cantrip is a reusable value: an LLM, an identity (who it is), and a circle (where it acts). When you cast or summon that value, an entity appears in the loop. The circle has a medium — the substrate the entity works in — plus gates (boundary crossings) and wards (hard constraints). The action space: A = (M + G) − W.

Every turn is recorded in the loom. Threads that end with done are terminated; threads cut short by wards are truncated. The entity is transient; the loom is durable.

1. Conversation Medium — The Baseline

The simplest cantrip: an LLM with a done gate in conversation mode. This is the standard tool-calling agent pattern — the model returns structured tool calls, the host executes them, results feed back in.

{:ok, cantrip} =
  new_cantrip.(
    identity: %{system_prompt: "You are a helpful assistant. Call done(answer) with your response."},
    circle: %{type: :conversation, gates: [:done], wards: [%{max_turns: 5}]}
  )

{:ok, result, _cantrip, loom, meta} = Cantrip.cast(cantrip, "What are the three laws of thermodynamics? Be brief.")

IO.puts("Result: #{inspect(result)}")
IO.puts("Turns: #{length(loom.turns)}")
LoomViz.table(loom, name: "1. Conversation Medium")

2. Code Medium — The Core Insight

Now the interesting part. In a code circle, the entity writes Elixir that runs on the BEAM. Variables persist across turns. Gates are anonymous functions in the sandbox. The entity builds up state the way you would in IEx — except the notebook writes itself.

Because code is compositional, the entity can compose actions nobody enumerated in advance. That’s the point.

{:ok, cantrip} =
  new_cantrip.(
    identity: %{
      system_prompt: """
      You are a data analyst working in an Elixir sandbox.
      You have these host functions available as anonymous functions (use dot-call syntax):
      - done.(answer) — return your final answer and terminate

      Write Elixir code. Variables persist across turns — define data in one
      turn, compute on it in the next. Each response should be a short code
      snippet that does ONE thing: define data, transform it, or call done.
      Do NOT call done in the same turn where you define your data.
      """
    },
    circle: %{type: :code, gates: [:done], wards: [%{max_turns: 8}]}
  )

{:ok, result, _cantrip, loom, _meta} =
  Cantrip.cast(cantrip, """
  Here's quarterly revenue data:
  Q1: 12_000, Q2: 13_200, Q3: 15_100, Q4: 14_800

  First, store the data. Then in a separate step, compute the quarter-over-quarter
  growth rates and identify which quarter had the highest growth.
  """)

IO.puts("Result: #{inspect(result)}")
LoomViz.table(loom, name: "2. Code Medium")

3. Terminated vs. Truncated

Wards are structural, not advisory. If the turn limit is 2, turn 3 doesn’t happen — the thread is truncated. Compare that to a thread where the entity calls done — that’s terminated. The distinction matters for training data: terminated threads completed their task; truncated threads were cut short.

# Terminated: enough turns to finish
{:ok, t_cantrip} =
  new_cantrip.(
    identity: %{system_prompt: "Answer the question. Call done(answer) with your response."},
    circle: %{type: :conversation, gates: [:done, :echo], wards: [%{max_turns: 5}]}
  )

{:ok, t_result, _, t_loom, t_meta} = Cantrip.cast(t_cantrip, "What is 2 + 2?")

# Truncated: only 1 turn allowed, and we give it a hard problem
{:ok, tr_cantrip} =
  new_cantrip.(
    identity: %{
      system_prompt: """
      You must call echo() to think through each step before answering.
      Think through at least 3 steps before calling done().
      """
    },
    circle: %{type: :conversation, gates: [:done, :echo], wards: [%{max_turns: 1}]}
  )

tr_result = Cantrip.cast(tr_cantrip, "Explain the proof of Gödel's incompleteness theorem step by step")

{tr_result_val, tr_loom, tr_meta} =
  case tr_result do
    {:ok, r, _, l, m} -> {r, l, m}
    {:error, r, _} -> {r, %{turns: []}, %{}}
  end

tr_reason = tr_meta[:termination_reason] || (if tr_result_val == nil, do: "max_turns (truncated)", else: "done")

Kino.Layout.grid([
  Kino.Markdown.new("**Terminated** — result: `#{inspect(t_result)}`, turns: #{length(t_loom.turns)}, reason: `#{t_meta[:termination_reason] || "done"}`"),
  LoomViz.table(t_loom, name: "3a. Terminated"),
  Kino.Markdown.new("**Truncated** — result: `#{inspect(tr_result_val)}`, turns: #{length(tr_loom.turns)}, reason: `#{tr_reason}`"),
  if(length(tr_loom.turns) > 0, do: LoomViz.table(tr_loom, name: "3b. Truncated"), else: Kino.Text.new("(no turns recorded)"))
], columns: 1)

4. Gates and Error Recovery

Gates let the entity reach outside the circle. When a gate returns an error, the entity sees it as an observation and can adjust. “Error is steering” — the model doesn’t crash, it adapts.

# A gate that always fails
broken_gate = %{
  name: "fetch_api",
  result: {:error, "503 Service Unavailable"},
  parameters: %{
    type: "object",
    properties: %{url: %{type: "string", description: "URL to fetch"}},
    required: ["url"]
  }
}

# A gate that works
working_gate = %{
  name: "local_cache",
  result: ~s({"temperature": 18, "conditions": "overcast", "city": "Portland"}),
  parameters: %{
    type: "object",
    properties: %{query: %{type: "string", description: "Cache lookup key"}},
    required: ["query"]
  }
}

{:ok, cantrip} =
  new_cantrip.(
    identity: %{
      system_prompt: """
      You are a weather reporter. You have two data sources:
      - fetch_api(url) — live weather API (may be down)
      - local_cache(query) — cached weather data (always available)

      Try the API first. If it fails, fall back to the cache.
      Call done(answer) with the weather report.
      """
    },
    circle: %{
      type: :conversation,
      gates: [:done, broken_gate, working_gate],
      wards: [%{max_turns: 10}]
    }
  )

{:ok, result, _cantrip, loom, _meta} = Cantrip.cast(cantrip, "What's the weather in Portland?")

IO.puts("Result: #{result}")
LoomViz.table(loom, name: "4. Error Recovery")

5. Composition — Parent and Child

In code medium, the entity composes with the public Cantrip API. It can create child cantrips with Cantrip.new/1, run them with Cantrip.cast/3 or Cantrip.cast_batch/2, and synthesize the returned summaries. max_depth prevents infinite recursion.

{:ok, cantrip} =
  new_cantrip.(
    identity: %{
      system_prompt: """
      You are a manager agent in an Elixir code sandbox.
      Delegate work by constructing child cantrips with Cantrip.new/1 and
      running them with Cantrip.cast/3.

      Use done.(answer) to return your final answer.
      Delegate the actual computation to a child, then synthesize.
      """
    },
    circle: %{
      type: :code,
      gates: [:done],
      wards: [%{max_turns: 8}, %{max_depth: 1}]
    }
  )

{:ok, result, _cantrip, loom, _meta} =
  Cantrip.cast(cantrip, """
  I need two things:
  1. The first 10 Fibonacci numbers
  2. Their sum
  Delegate the Fibonacci computation to a child entity, then compute the sum yourself.
  """)

IO.puts("Result: #{inspect(result)}")
LoomViz.table(loom, name: "5. Composition")

6. Fork — Rewind and Replay

Cantrip.Loom.fork/4 restarts from a prior turn. The code medium snapshots bindings at each turn, so forking restores sandbox state without replay.

We run a code cantrip that defines data and computes the mean, then fork from turn 1 — the data variable is still bound, and the entity takes a different analytical path.

{:ok, cantrip} =
  new_cantrip.(
    identity: %{
      system_prompt: """
      You are a data analyst in an Elixir sandbox.
      Use done.(answer) to return results.
      """
    },
    circle: %{type: :code, gates: [:done], wards: [%{max_turns: 8}]}
  )

# Original run
{:ok, original_result, next_cantrip, original_loom, _meta} =
  Cantrip.cast(cantrip, "Define a list called `data` with values [10, 20, 30, 40, 50] and compute the mean.")

IO.puts("Original: #{inspect(original_result)}")

# Fork from turn 1 — the `data` variable should still be bound
fork_result =
  Cantrip.Loom.fork(next_cantrip, original_loom, 1, %{
    intent: "Now compute the standard deviation of the `data` list that's already defined."
  })

case fork_result do
  {:ok, result, _, fork_loom, _} ->
    IO.puts("Fork: #{inspect(result)}")

    Kino.Layout.grid([
      LoomViz.table(original_loom, name: "6a. Original Run"),
      LoomViz.table(fork_loom, name: "6b. Forked from Turn 1")
    ], columns: 1)

  {:error, reason, _} ->
    IO.puts("Fork failed: #{inspect(reason)}")
    LoomViz.table(original_loom, name: "6. Original Run (fork failed)")
end

7. Persistent Entities — Memory Across Episodes

Cantrip.summon/1 creates a GenServer that stays alive. Each Cantrip.send/2 runs a new episode, but state accumulates — loom, code bindings, message history. The OTP process model maps directly onto the entity lifecycle.

{:ok, cantrip} =
  new_cantrip.(
    identity: %{
      system_prompt: """
      You are a persistent analyst in an Elixir sandbox. State carries across episodes.
      Variables you define persist. Use done.(answer) to finish each episode.
      """
    },
    circle: %{type: :code, gates: [:done], wards: [%{max_turns: 8}]}
  )

{:ok, pid} = Cantrip.summon(cantrip)

# Episode 1: set up data
{:ok, r1, _, loom1, _} = Cantrip.send(pid, "Create a map called `metrics` with keys :revenue, :cost, :profit set to 100, 60, 40. Confirm what you stored.")

IO.puts("Episode 1: #{inspect(r1)}")

# Episode 2: use the data from episode 1
{:ok, r2, _, loom2, _} = Cantrip.send(pid, "Using the `metrics` map from before, compute the profit margin as a percentage.")

IO.puts("Episode 2: #{inspect(r2)}")

Kino.Layout.grid([
  LoomViz.table(loom1, name: "7a. Episode 1"),
  LoomViz.table(loom2, name: "7b. Episode 2 (accumulated)")
], columns: 1)

8. Familiar — Codebase Coordinator

The Familiar is the same abstraction with the codebase-facing circle already assembled. It is still a cantrip value: LLM, identity, medium, gates, wards, and loom storage. The difference is that its identity knows how to coordinate workspace inquiry, delegate to child cantrips, and preserve a durable trace.

Use it when the thing you want is not “one answer from an LLM,” but an entity that can keep working in a codebase-shaped environment.

{:ok, familiar} =
  Cantrip.Familiar.new(
    llm: llm,
    root: Path.expand(Path.join(__DIR__, "..")),
    loom_path: "tmp/cantrip-demo-familiar.jsonl",
    max_turns: 6
  )

{:ok, result, _cantrip, loom, meta} =
  Cantrip.cast(familiar, """
  Inspect this package at a high level. Report the main public surfaces and
  say when someone should use the Familiar instead of assembling a cantrip
  by hand. Keep the answer brief.
  """)

IO.puts("Result: #{inspect(result)}")
IO.puts("Reason: #{inspect(meta[:termination_reason])}")
LoomViz.table(loom, name: "8. Familiar")

9. Telemetry

The runtime emits :telemetry events at entity start/stop, turn start/stop, gate start/stop, and code evaluation — all with durations. Attach handlers for observability without touching application code.

defmodule TelemetryHandler do
  def handle_event(event, measurements, metadata, frame) do
    time = DateTime.utc_now() |> Calendar.strftime("%H:%M:%S.%f")

    label =
      event |> Enum.drop(1) |> Enum.map_join(" ", &String.upcase(to_string(&1)))

    detail =
      case event do
        [:cantrip, :turn, :stop] -> "turn ##{metadata.turn_number} (#{div(measurements.duration, 1_000)} µs)"
        [:cantrip, :gate, :stop] -> "#{metadata.gate_name} (#{div(measurements.duration, 1_000)} µs)#{if metadata.is_error, do: " [ERROR]", else: ""}"
        [:cantrip, :entity, :start] -> "intent=#{String.slice(inspect(metadata.intent), 0, 60)}"
        [:cantrip, :entity, :stop] -> "reason=#{metadata.reason}"
        [:cantrip, :code, :eval] -> "(#{div(measurements.duration, 1_000)} µs)"
        _ -> ""
      end

    html = Kino.HTML.new("""
    
      #{time} #{label} #{detail}
    
    """)

    Kino.Frame.append(frame, html)
  end
end

frame = Kino.Frame.new()
Kino.render(frame)

for event <- [
  [:cantrip, :entity, :start], [:cantrip, :entity, :stop],
  [:cantrip, :turn, :start], [:cantrip, :turn, :stop],
  [:cantrip, :gate, :start], [:cantrip, :gate, :stop],
  [:cantrip, :code, :eval]
] do
  id = "demo-#{inspect(event)}"
  :telemetry.detach(id)
  :telemetry.attach(id, event, &amp;TelemetryHandler.handle_event/4, frame)
end

Kino.Text.new("Telemetry attached — run the next cell.")
{:ok, cantrip} =
  new_cantrip.(
    identity: %{
      system_prompt: """
      You are an analyst in an Elixir code sandbox.
      Use echo.() to think aloud and done.() to finish.
      """
    },
    circle: %{type: :code, gates: [:done, :echo], wards: [%{max_turns: 6}]}
  )

{:ok, result, _, _, _} =
  Cantrip.cast(cantrip, "Compute the factorial of 10, showing your work with echo.")

IO.puts("Result: #{inspect(result)}")

Reference

Section Concept Package Surface
1 Conversation medium, basic cast Cantrip.new/1, Cantrip.cast/3
2 Code medium, persistent bindings circle: %{type: :code}
3 Terminated vs. truncated max_turns, termination metadata
4 Custom gates, error as steering gate maps and observations
5 Parent/child composition Cantrip.new/1, cast/3, cast_batch/2
6 Fork from prior turn Cantrip.Loom.fork/4
7 Persistent entity lifecycle Cantrip.summon/1, Cantrip.send/3
8 Familiar coordinator Cantrip.Familiar.new/1
9 Telemetry events :telemetry events