For agents in PTC-Lisp mode (output: :ptc_lisp, the default), the
ptc_transport option controls how the LLM ships its program to PtcRunner.
Both transports run programs in the same sandbox, with the same
(tool/name ...) namespace for app tools and the same memory / journal /
signature semantics. They differ only in how the program crosses the wire.
# Default — :content. No option needed.
SubAgent.new(prompt: "...", tools: tools)
# Opt in — :tool_call. Same agent shape, different wire format.
SubAgent.new(prompt: "...", tools: tools, ptc_transport: :tool_call)TL;DR
| Transport | Default? | Wire format | Pick when |
|---|---|---|---|
:content | yes | Markdown-fenced PTC-Lisp in assistant message | One program is enough. Lowest latency, lowest cost, single LLM turn. |
:tool_call | opt-in | Native call to one internal lisp_eval tool whose program arg is the source | Native tool calling is materially more reliable than fenced-code parsing on this provider/model, or the workload truly needs iterative refinement. |
:content is the default and stays the default in this release. :tool_call
is opt-in. Neither replaces the other.
How each transport works
:content (default)
The LLM responds with a single markdown-fenced PTC-Lisp block in the assistant
message. PtcRunner parses it and runs it in the sandbox. Tools registered on
the agent are available as (tool/name ...) from inside the program.
Typically one LLM turn produces one program that does everything: fan out to
tools, filter, aggregate, return.
This is the one program, one deterministic orchestration shape. It is predictable, cacheable, and almost always cheaper than tool-call mode for the same workload.
:tool_call (opt-in)
PtcRunner exposes exactly one provider-native tool, lisp_eval, whose
single argument is the PTC-Lisp source string. App tools are not exposed
as native tools — only lisp_eval is. App tools remain available
inside the sandboxed program as (tool/name ...), identically to
:content mode.
Per assistant turn, the LLM may:
- Call
lisp_evalonce. PtcRunner runs the program, returns the result as a tool-result message, and the loop continues to the next turn. - Return a final answer directly as content (no tool call). PtcRunner
validates the answer against
signature:exactly like(return v)would. Direct final answers are allowed before or after any execution-tool calls — a simple prompt the model can answer without computation stays one turn. - Call
(return v)or(fail v)from inside a program to terminate immediately, with the final tool result paired against the originating call.
:tool_call therefore turns one PTC-Lisp program into a ReAct-style loop: the
model calls lisp_eval zero or more times, looks at intermediate
results, and writes the next program (or the final answer). That extra
round-tripping is a tradeoff, not an upgrade.
Why app tools stay inside PTC-Lisp
The most common question on first read of :tool_call is "why not just expose
my app tools natively?" The deliberate two-layer model is:
| Layer | What it is | How the LLM invokes it |
|---|---|---|
| Provider-native | Exactly one tool: lisp_eval. | Native function-calling on the LLM provider. |
| PTC-Lisp | All app tools registered on the agent. | From inside a PTC-Lisp program: (tool/name ...). |
Keeping app tools inside the sandbox preserves the guarantees PtcRunner exists to provide:
- Determinism and observability. Every app-tool invocation is traced,
cacheable (
cache: true), bounded bymax_tool_calls, and re-entrant undertask/ journaling. - Parallel execution.
(pmap ...)and(pcalls ...)fan out app tools in parallel inside one program. Native provider tool calling gives you neither the parallel primitive nor the deterministic ordering. - One transcript shape. Whatever transport you pick, the program is the same and the trace is the same. Only the program-delivery wire changes.
If you want native-only tool calling without PTC-Lisp at all, that's
output: :text with tools:. See Text Mode. It's a
different product, not a different transport.
Choosing a transport
Stay on :content (default) when
- You don't have a specific reason to switch.
:contentworks on every provider PtcRunner supports, including providers without native tool calling. - Cost and latency matter. One LLM turn is cheaper than two, and
:contenthits one turn for the typical "fan out + aggregate + return" shape. - The model reliably emits a single fenced block. Modern Anthropic, OpenAI, and capable openrouter-hosted models do this well in PtcRunner's default prompt.
- Your workload doesn't actually need to look at an intermediate result before writing the next program — you can plan the whole program up front.
Consider :tool_call when
- The provider/model you're locked into is materially more reliable at native tool calling than at "emit exactly one fenced clojure block." Some smaller models follow tool-calling schemas more reliably than they follow output-format instructions.
- The workload genuinely needs iterative refinement across multiple program executions: write program → inspect result → write next program → ... → return. This is a real ReAct pattern that doesn't compress into one program. It exists, but it's rarer than people think.
- You want to compare
:contentvs:tool_callon a real workload of your own (turn count, cost, error rate) before standardizing on one. Both are supported indefinitely; pick on data, not preference.
Why "tool calling is more native, therefore better" is wrong
It is tempting to read :tool_call as the modern, production-grade option and
:content as the legacy fenced-code path. That framing is incorrect.
:contentis not legacy. It is the default, and stays the default in this release. PtcRunner's whole value proposition — the LLM writes a program, the runtime executes it deterministically — works equally well in either transport.:tool_callis not magic. It does not improve the program, the sandbox, or the tool surface. It only changes how the program string is delivered.:tool_calladds turns. A workload that takes one turn in:contentoften takes two or three in:tool_call(calllisp_eval, get result, return final answer). Pay for the extra turns deliberately.:tool_callcan hurt reliability on capable models. Models that already emit fenced code cleanly (e.g., recent Anthropic) sometimes do worse on:tool_call: the loop encourages them to fragment one-program work into multiplelisp_evalcalls, replan between turns, or embed the answer in conversational prose. This is not hypothetical — measure before switching.- Each
:tool_callturn re-ships thelisp_evalschema. In practice that's ~800 input tokens of overhead per turn. On simple workloads, this can dominate the total prompt cost.
Empirical note (one small benchmark)
A 7-query demo suite — 3 in-memory queries, 4 multi-turn search/fetch queries — run 5 times per cell:
| Model | :content pass | :tool_call pass | :content wall | :tool_call wall | :tool_call input tokens |
|---|---|---|---|---|---|
| Claude Haiku 4.5 | 34/35 | 27/35 | 94 s | 162 s | +162 % |
| Gemini 3.1 Flash Lite | 35/35 | 34/35 | 69 s | 61 s | +58 % |
Reading the table:
- On Haiku,
:tool_calldropped pass rate (97 % → 77 %) and roughly doubled latency.:contentis the right default here. - On Gemini Flash Lite, pass rates were close.
:tool_callwas ~26 % faster on the multi-turn tool queries but ~25 % slower on the simple in-memory queries, and always cost more input tokens. - The right transport depends on (model × workload), not just model. One small benchmark on one suite is not a universal recommendation — reproduce the comparison on your own workload before standardizing.
Provider compatibility
:tool_call requires a provider/model with native tool calling. If you call a
non-tool-calling model with ptc_transport: :tool_call, the run surfaces as
{:error, %Step{}} with step.fail.reason == :llm_error and the provider's
own reason string in step.fail.message. There is no automatic fallback to
:content.
Common cases:
- Most Anthropic and OpenAI models — supported.
- Bedrock-hosted Anthropic / supported OpenAI variants — supported.
- OpenRouter — supported when the upstream model itself supports tool calling. PtcRunner passes through whatever the upstream offers.
- Ollama — generally not supported.
openai-compat:endpoints without tool calling — not supported.
When in doubt, leave ptc_transport at its default. See the
LLM setup guide for provider compatibility
details.
Don't
- Don't pass
ptc_transporttogether withoutput: :text— raisesArgumentError. The transport only applies to PTC-Lisp programs. - Don't define an app tool named
lisp_eval. The name is reserved globally; the validator rejects it regardless ofptc_transport. - Don't switch transports mid-conversation.
ptc_transportis part of the agent contract; pick once per agent and stay there.
See also
- LLM setup guide — provider compatibility for tool calling.
- Output Modes in an App Loop livebook —
runnable walkthrough that demos
:contentand:tool_callover the same scenario. - Troubleshooting —
failure modes specific to
:tool_call.