Getting Started with SubAgents
View SourceThis guide walks you through your first SubAgent - from a minimal example to understanding the core execution model.
Prerequisites
- Elixir 1.14+
- An LLM provider (OpenRouter, Anthropic, OpenAI, etc.)
The Simplest SubAgent
{:ok, step} = PtcRunner.SubAgent.run(
"How many r's are in raspberry?",
llm: my_llm
)
step.return #=> 3That's it. No tools, no signature, no validation - just a prompt and an LLM.
Why This Matters
The SubAgent doesn't answer directly - it writes a program that computes the answer:
(count (filter #(= % "r") (seq "raspberry")))This is the core insight of PTC (Programmatic Tool Calling): instead of asking the LLM to be the computer, ask it to program the computer. The LLM reasons and generates code; the actual computation runs in a sandboxed interpreter where results are deterministic.
With Context
Pass data to the prompt using {{placeholders}}:
{:ok, step} = PtcRunner.SubAgent.run(
"Summarize in one sentence: {{text}}",
context: %{text: "Long article about climate change..."},
llm: my_llm
)
step.return #=> "Climate change poses significant global challenges..."With Type Validation
Add a signature to validate the output structure:
{:ok, step} = PtcRunner.SubAgent.run(
"Rate this review sentiment",
context: %{review: "Great product, love it!"},
signature: "{sentiment :string, score :float}",
llm: my_llm
)
step.return.sentiment #=> "positive"
step.return.score #=> 0.95JSON Mode (Simpler Alternative)
For classification, extraction, and reasoning tasks that don't need tools, use output: :json:
{:ok, step} = PtcRunner.SubAgent.run(
"Extract the person's name and age",
context: %{text: "John is 25 years old"},
output: :json,
signature: "(text :string) -> {name :string, age :int}",
llm: my_llm
)
step.return.name #=> "John"
step.return.age #=> 25JSON mode skips PTC-Lisp entirely - the LLM returns structured JSON directly, validated against your signature. Use it when you need structured output but not computation or tool calls.
Constraints: JSON mode requires a signature, cannot use tools, and doesn't support compression or firewall fields.
Adding Tools
Tools let the agent call functions to gather information:
{:ok, step} = PtcRunner.SubAgent.run(
"What is the most expensive product?",
signature: "{name :string, price :float}",
tools: %{"list_products" => &MyApp.Products.list/0},
llm: my_llm
)
step.return.name #=> "Widget Pro"
step.return.price #=> 299.99With tools, the SubAgent enters an agentic loop - it calls tools and reasons until it has enough information to return.
Execution Behavior
| Mode | Condition | Behavior |
|---|---|---|
| Single-shot | max_turns: 1 and no tools | One LLM call, expression returned directly |
| Loop | Otherwise | Multiple turns until (return ...) or (fail ...) |
In single-shot mode, the LLM's expression is evaluated and returned directly. In loop mode, the agent must explicitly call return or fail to complete.
Common Pitfall: If your agent produces correct results but keeps looping until
max_turns_exceeded, it's likely in loop mode without callingreturn. Either setmax_turns: 1for single-shot execution, or ensure your prompt guides the LLM to use(return {:value ...})when done.
Debugging Execution
To see what the agent is doing, use PtcRunner.SubAgent.Debug.print_trace/2:
{:ok, step} = SubAgent.run(prompt, llm: my_llm)
PtcRunner.SubAgent.Debug.print_trace(step)For more detail, include raw LLM output (reasoning) or the actual messages sent:
# Include LLM reasoning/commentary
PtcRunner.SubAgent.Debug.print_trace(step, raw: true)
# Show full messages sent to LLM
PtcRunner.SubAgent.Debug.print_trace(step, messages: true)This is essential for identifying why a model might be failing or ignoring tool instructions.
More options: See Observability for compression, telemetry, and production tips.
Signatures (Optional)
Signatures define a contract for inputs and outputs:
# Output only
signature: "{name :string, price :float}"
# With inputs (for reusable agents)
signature: "(query :string) -> [{id :int, title :string}]"When provided, signatures:
- Validate return data (agent retries on mismatch)
- Document expected shape to the LLM
- Give your Elixir code predictable types
See Signature Syntax for full syntax.
Providing an LLM
SubAgent is provider-agnostic. You supply a callback function:
llm = fn %{system: system, messages: messages} ->
# Call your LLM provider here
{:ok, response_text}
# Or include token counts for usage stats:
# {:ok, %{content: response_text, tokens: %{input: 100, output: 50}}}
end
PtcRunner.SubAgent.run(prompt, llm: llm, signature: "...")The callback receives:
| Key | Type | Description |
|---|---|---|
system | String.t() | System prompt with instructions |
messages | [map()] | Conversation history |
turn | integer() | Current turn number |
tool_names | [String.t()] | Available tool names |
llm_opts | map() | Custom options passed through |
Using Atoms with a Registry
For convenience, you can use atoms like :haiku or :sonnet by providing an llm_registry:
# Define your callbacks
defmodule MyApp.LLM do
def haiku(input), do: call_anthropic("claude-3-haiku-20240307", input)
def sonnet(input), do: call_anthropic("claude-3-5-sonnet-20241022", input)
end
# Create registry
registry = %{
haiku: &MyApp.LLM.haiku/1,
sonnet: &MyApp.LLM.sonnet/1
}
# Use atoms - resolved via registry
PtcRunner.SubAgent.run(prompt,
llm: :sonnet,
llm_registry: registry,
signature: "..."
)The registry is inherited by child SubAgents, so you only pass it once at the top level. See PtcRunner.SubAgent.run/2 for more details.
App-Level Default Registry
For applications that want to avoid passing the registry on every call:
# In your application.ex start/2
def start(_type, _args) do
Application.put_env(:ptc_runner, :default_llm_registry, MyApp.llm_registry())
# ... rest of supervision tree
end
# Now llm_registry is optional - falls back to default
PtcRunner.SubAgent.run(prompt, llm: :sonnet, signature: "...")This is useful for production apps but not available in Livebook (use explicit registry there).
Example with ReqLLM
defmodule MyApp.LLM do
@timeout 30_000
def callback(model \\ "openrouter:anthropic/claude-haiku-4.5") do
fn %{system: system, messages: messages} ->
full_messages = [%{role: :system, content: system} | messages]
case ReqLLM.generate_text(model, full_messages, receive_timeout: @timeout) do
{:ok, %ReqLLM.Response{} = r} ->
usage = ReqLLM.Response.usage(r)
{:ok, %{
content: ReqLLM.Response.text(r),
tokens: %{input: usage[:input_tokens] || 0, output: usage[:output_tokens] || 0}
}}
{:error, reason} ->
{:error, reason}
end
end
end
end
# Usage
llm = MyApp.LLM.callback()
PtcRunner.SubAgent.run(prompt, llm: llm, signature: "...")Note: The callback must include the
systemprompt in the messages sent to the LLM. The SubAgent's system prompt contains critical PTC-Lisp instructions that guide the LLM to output valid programs.
Defining Tools
Tools are functions the SubAgent can call. Provide them as a map:
tools = %{
"list_products" => &MyApp.Products.list/0,
"get_product" => &MyApp.Products.get/1,
"search" => fn %{query: q, limit: l} -> MyApp.search(q, l) end
}Auto-Extraction from @spec and @doc
Tool signatures and descriptions are auto-extracted when available:
# In your module
@doc "Search for items matching the query string"
@spec search(String.t(), integer()) :: [map()]
def search(query, limit), do: ...
# Auto-extracted:
# signature: "(query :string, limit :int) -> [:map]"
# description: "Search for items matching the query string"
tools = %{"search" => &MyApp.search/2}Explicit Signatures
For functions without specs, provide a signature explicitly:
tools = %{
"search" => {&MyApp.search/2, "(query :string, limit :int) -> [{id :int}]"}
}Adding Descriptions
Descriptions help the LLM understand when and how to use each tool. Use keyword list format:
tools = %{
"search" => {&MyApp.search/2,
signature: "(query :string, limit :int?) -> [{id :int, title :string}]",
description: "Search for items matching query. Returns up to limit results (default 10)."
},
"get_user" => {&MyApp.get_user/1,
signature: "(id :int) -> {name :string, email :string?}",
description: "Fetch user by ID. Returns nil if not found."
}
}Tool Format Summary
| Format | When to Use |
|---|---|
&Mod.fun/n | Functions with @spec and @doc |
{fun, "signature"} | Explicit signature, no description needed |
{fun, signature: "...", description: "..."} | Production tools with full documentation |
fn args -> ... end | Quick inline functions |
Agent as Data
For reusable agents, create the struct separately:
# Define once
product_finder = PtcRunner.SubAgent.new(
prompt: "Find the most expensive product",
signature: "{name :string, price :float}",
tools: product_tools,
max_turns: 5
)
# Execute with runtime params
{:ok, step} = PtcRunner.SubAgent.run(product_finder, llm: my_llm)This separation enables testing, composition, and reuse.
Additional Struct Fields
SubAgents support additional optional fields for documentation and output control:
PtcRunner.SubAgent.new(
prompt: "Find products matching {{query}}",
signature: "(query :string) -> [{name :string, price :float}]",
tools: product_tools,
# Human-readable description for external documentation
description: "Searches the product catalog and returns matching items",
# Descriptions for individual signature fields
field_descriptions: %{
query: "Search term to match against product names",
name: "Product name",
price: "Price in USD"
},
# Descriptions for context variables (shown in Data Inventory)
context_descriptions: %{
user_id: "ID of the customer performing the search",
region: "ISO region code (e.g. US, UK)"
},
# Output formatting options (shown with defaults)
format_options: [
feedback_limit: 10, # max collection items in turn feedback
feedback_max_chars: 512, # max chars in turn feedback
history_max_bytes: 512, # truncation limit for *1/*2/*3 history
result_limit: 50, # inspect :limit for final result
result_max_chars: 500, # final string truncation
max_print_length: 2000 # max chars per println call
],
# Float precision for output formatting (default: 2)
float_precision: 2
)These fields are used by the v2 namespace model for enhanced documentation flow and output control. See PtcRunner.SubAgent for full details.
The Firewall Convention
Fields prefixed with _ are firewalled - available to your Elixir code and the agent's programs, but hidden from LLM prompt history:
signature: "{summary :string, count :int, _email_ids [:int]}"This keeps parent agent context lean while preserving full data access. See Core Concepts for details.
State Persistence
Use def to store values that persist across turns within a single run:
(def cache result) ; store
cache ; access as plain symbolUse defn to define reusable functions:
(defn expensive? [item] (> (:price item) 1000))
(filter expensive? data/items)State is scoped per-agent and hidden from prompts. See Core Concepts for details.
See Also
- Core Concepts - Context, memory, and the firewall convention
- Observability - Telemetry, debug mode, and tracing
- Patterns - Chaining, orchestration, and composition
- Signature Syntax - Full signature syntax reference
- Advanced Topics - ReAct patterns and the compile pattern
PtcRunner.SubAgent- API reference