Execute PTC programs written in Lisp DSL (Clojure subset).
PTC-Lisp enables LLMs to write safe programs that orchestrate tools and transform data. Unlike raw code execution (Python, JavaScript), PTC-Lisp provides safety by design: no filesystem/network access, no unbounded recursion, and deterministic execution in isolated BEAM processes with resource limits.
See the PTC-Lisp Specification for the complete language reference.
Tool Registration
Tools are functions that receive a map of arguments and return results.
Note: tool names use kebab-case in Lisp (e.g., "get-user" not "get_user"):
tools = %{
"get-user" => fn %{"id" => id} -> MyApp.Users.get(id) end,
"search" => fn %{"query" => q} -> MyApp.Search.run(q) end
}
PtcRunner.Lisp.run(~S|(tool/get-user {:id 123})|, tools: tools)Contract:
- Receives:
map()of arguments (may be empty%{}) - Returns: Any Elixir term (maps, lists, primitives)
- Should not raise (return
{:error, reason}for errors)
Public tools declared with cache: true reuse successful results only within
the current evaluation; private tools never cache. Every run/2 starts with
an empty evaluator-owned cache, and cache entries are neither accepted from
callers nor returned in PtcRunner.Lisp.Result.
Summary
Functions
Converts a PTC-Lisp memory map into the public memory representation.
Converts native PTC-Lisp runtime values into an inert public Elixir representation.
Format an error tuple into a human-readable string.
Format a PTC-Lisp value as Clojure-style syntax for display.
Run a PTC-Lisp program.
Validate PTC-Lisp source code without executing it.
Functions
@spec externalize_memory(map()) :: map() | {:error, {:java_projection_error | :lisp_value_projection_error | :symbol_ref_projection_error, term()}}
Converts a PTC-Lisp memory map into the public memory representation.
Unlike externalize_value/1, this also normalizes top-level def binding
keys through the bounded source vocabulary.
@spec externalize_value(term()) :: term() | {:error, {:java_projection_error | :lisp_value_projection_error | :symbol_ref_projection_error, term()}}
Converts native PTC-Lisp runtime values into an inert public Elixir representation.
Stateful hosts may keep native values internally between turns, but should use this at public observation boundaries such as final steps and trace events. Executable closures, builtins, composed callables, runtime callables, and plain BEAM functions are replaced recursively with deterministic display labels that cannot retain callable state, captured environments, or metadata. If distinct map keys or set members collapse to the same inert representation, every entry is retained with an opaque inert collision wrapper. Strict Kernel boundaries use the same projection and reject those collisions instead.
Format an error tuple into a human-readable string.
Useful for displaying errors to users or feeding back to LLMs for retry.
Examples
iex> PtcRunner.Lisp.format_error({:parse_error, "unexpected token"})
"Parse error: unexpected token"
iex> PtcRunner.Lisp.format_error({:eval_error, "undefined variable: x"})
"Eval error: undefined variable: x"
Format a PTC-Lisp value as Clojure-style syntax for display.
This is the public wrapper around PtcRunner.Lisp.Format.to_clojure/2 used by
LLM-facing renderers and embedding applications.
Returns {formatted_string, truncated?}.
Examples
iex> PtcRunner.Lisp.format_value(%{count: 2, ids: [1, 2]})
{"{:count 2 :ids [1 2]}", false}
iex> PtcRunner.Lisp.format_value([1, 2, 3], limit: 2)
{"[1 2 ...] (2/3)", true}
@spec run( String.t(), keyword() ) :: {:ok, PtcRunner.Lisp.Result.t()} | {:error, PtcRunner.Lisp.Result.t()}
Run a PTC-Lisp program.
Parameters
source: PTC-Lisp source code as a stringopts: Keyword list of options:context- Initial context map (default: %{}):memory- Initial memory map (default: %{}):turn_history- Prior turn return values, oldest first, used by*1,*2, and*3(default:[]):tools- Map of tool names to functions (default: %{}):signature- Optional signature string for return value validation:float_precision- Number of decimal places for floats in result (default: nil = full precision):timeout- Timeout in milliseconds for entire sandbox execution (default: 1000, configurable viaconfig :ptc_runner, :default_timeout):compile_timeout- Timeout in milliseconds for the compile phase (parse + analyze) (default: 5000):compile_max_heap- Compile-worker heap ceiling in words (default: the:max_heapvalue). No ambient application default is consulted, so a sealed run's compile ceiling comes only from its own options.:pmap_timeout- Shared absolute deadline in milliseconds for each pmap/pcalls operation, including nested parallel calls (default: 5000). Increase for LLM-backed tools.:parallel_deadline_cap- Absolute monotonic-time ceiling in milliseconds clamping every parallel deadline regardless of when the operation starts (default: the:run_deadline_msvalue). A parallel operation started late in a run therefore cannot outlive the run.:pmap_max_concurrency- Local pmap/pcalls scheduling window — max tasks one call keeps in flight (default: the build-timeSystem.schedulers_online() * 2, frozen into the semantic revision). Reduce to avoid overflowing connection pools. The HARD aggregate cap is:max_parallel_workers.:max_heap- Program heap budget in words ABOVE the measured environment baseline (default: 1_250_000, configurable viaconfig :ptc_runner, :default_max_heap). Host-provided data (context,:memory, tool closures, the parsed program) is measured after spawn and excluded from this budget — seePtcRunner.Sandboxfor the re-baseline semantics.:setup_max_heap- Hard heap ceiling in words while the host environment is copied into the sandbox, before the re-baseline (default:4 × max_heap). Callers granting large tools/memory must raise this explicitly; exceeding it fails with a setup-phase:memory_exceedederror.:worker_max_heap- Fixedmax_heap_size(words) for every pmap/pcalls worker, top-level and nested (default: the:max_heapvalue):max_parallel_workers- Global cap on pmap/pcalls worker processes alive at once across the whole run, at any nesting depth (default: 8). Aggregate live parallel heap ≈max_parallel_workers * worker_max_heap. A pmap/pcalls that cannot get a slot fails with:parallel_capacity_exceeded.:max_symbols- Max unique symbols/keywords allowed (default: 10_000):max_program_bytes- Max source code size in bytes (default: 1_000_000):max_print_length- Max characters perprintlncall (default: 2000):filter_context- Filter context to only include accessed data keys (default: true):strict_data- When true, a missingdata/<name>is a runtime error instead ofnil(default: false). The Kernel enables this at every one of its boundaries -- the workflow entry, a mission evaluation, and both REPL session kinds;run/2stays permissive.:data_grants- Optional sorteddata/<name>forms included in the missing-grant diagnostic under:strict_data. The Kernel passes the listPtcRunner.Lisp.DataKeys.source_referenceable_forms/1derives from the granted data, the same one the mission inventory publishes, rather than deriving it from context keys. When omitted, the diagnostic names the missing key without a grant list.:missing_data_params_message- Optional diagnostic used whendata/paramsis missing under:strict_data. The Kernel sets this so a no-params evaluation is not reported as a missing grant.:prelude- A compiled%PtcRunner.Lisp.Prelude{}artifact, a prelude SOURCE string, or a list of source-bearing selection maps accepted byPtcRunner.Lisp.Prelude.Bundle.compile/1to attach before user code. Source selections are concatenated and compiled once in explicit order after duplicate namespace rejection. The attached prelude's protected namespaces and public export table are consulted by the analyzer/evaluator so qualified prelude calls (e.g.crm/get-user) resolve, while private helpers stay user-invisible. Compile/attach failures return{:error, Step}. Attach-timetool:<name>requirements are checked against the granted:toolsmap. (default: nil):caller- Closed-set tag for telemetry. One of:direct,:kernel, or:repl(default::direct). Pure instrumentation: attached to[:ptc_runner, :lisp, :execute, *]events and otherwise discarded. Out-of-set values raiseArgumentError.
Telemetry
run/2 emits the following events:
[:ptc_runner, :lisp, :execute, :start]— measurementsmonotonic_time,system_time; metadatacaller,program_bytes,signature_supplied?.[:ptc_runner, :lisp, :execute, :stop]— measurementsduration,monotonic_time,result_bytes,prints_count,memory_bytes,eval_reductions; metadatacaller,program_bytes,signature_supplied?, and semanticoutcome.durationis in the emitter's native time unit,result_bytesis the external size of the returned value, andprints_countthe number of prints.memory_bytesandeval_reductionsare the sandbox child's own figures, the same pairstep.usagecarries:memory_bytesisProcess.info(child, :memory)read the instant the result was ready — a heap reading at return, not a peak and not RSS — andeval_reductionsis that child's reduction count. Both are0when a run failed before the child reported, which is not the same as a run that used nothing, so readoutcomebefore charting either.[:ptc_runner, :lisp, :execute, :exception]— measurementsduration,monotonic_time; metadatacaller,program_bytes,signature_supplied?, andexception_class. Exception reasons, stacktraces, source, arguments, and results are never attached.
Tool Cache Lifetime
A public tool configured with cache: true reuses successful results during
one evaluation. The evaluator derives cache identity from the arguments
prepared for the callback, including Java projection. Sequential and
higher-order calls observe earlier entries. Parallel workers start from the
same pre-parallel cache and do not observe sibling writes; their entries merge
in input order and become reusable after the parallel operation. Every
top-level call starts empty, and private tools never cache. Passing a
:tool_cache option raises ArgumentError, and cache entries are not included
in the returned Result.
Return Value
println writes only to an evaluation-local buffer. Successful and failed
results expose any retained entries through step.prints; a caller must
explicitly render or store them. The evaluator itself does not write those
entries to stdout or a canonical trace.
On success, returns:
{:ok, PtcRunner.Lisp.Result.t()}with:step.return: An inert public projection of the evaluated value. Executable values become typed display wrappers. Distinct map keys and set members that share one display form remain distinct through inert collision wrappers.step.memory: Data memory after execution. Direct Lisp callers can pass it back through the:memoryoption on a later eval; useexternalize_memory/1when you need a purely public observation shape. Therun/2result preserves supported closures and composed callable forms, including embedded builtin/tool runtime-callable references. It deliberately omits top-level runtime callables such as directly bound builtin/tool aliases and renders runtime callables nested in ordinary collection data as labels. Java callables are also rendered as labels, including when captured by a closure, so such a closure is observation-only on the public continuation path. Do not serializestep.memory(JSON, ETF-to-disk, database) between evals — serialization silently converts native values such as keywords into plain strings. For a bounded persistent REPL continuation, usePtcRunner.Kernel.ReplSessionfrom one stable owner process. It is process-affine; a separate supervised abstraction must serialize any multi-process frontend.step.usage: Execution metrics (duration_ms,memory_bytes,eval_reductions)
On error, returns:
{:error, PtcRunner.Lisp.Result.t()}with:step.fail.reason: Error reason atomstep.fail.message: Human-readable error descriptionstep.memory: Native continuation memory at the time of error, with the same callable caveats as successfulrun/2results. Setup-phase heap kills return empty memory because the oversized grant cannot safely be projected outside the bounded worker. Do not serialize continuation memory; useexternalize_memory/1for an observation-only projection.
Memory Contract
The top-level program value determines step.return without an implicit map
merge or special :return key handling. run/2 then projects that value to
its inert public representation. Storage is explicit: (def x v)
persists native v in memory (step.memory["x"]), and that memory can
survive across explicitly managed evaluations.
There are two deliberate host memory projections. Ordinary embedders may use
run/2 to continue data definitions, closures that do not capture Java
callables, and supported composed callable forms by threading step.memory
directly into the next call's :memory option. Top-level direct
runtime-callable aliases and Java callables are not preserved on this public
path. Hosts that must preserve every runtime callable, including direct
builtin/tool aliases and Java callables captured by closures, use the
internal run_native/2 continuation path with
preserve_runtime_callables: true, as PtcRunner.Kernel does. A host must
not substitute the JSON/public memory projection for either continuation
path: that projection is observation-only and cannot retain executable
values.
:turn_history is separate from memory. Hosts pass a list of prior
successful step.return values in chronological order; *1 reads the most
recent value, *2 the previous value, and *3 the third-most-recent value.
Missing history reads return nil. run/2 does not mutate the supplied
history; callers that want REPL semantics should append step.return only
after a successful run and keep their chosen bounded depth. For direct
embedding use, PtcRunner.Kernel.ReplSession implements this contract with a
default depth of 3.
Related modules:
PtcRunner.Kernel- Executes bounded Kernel workflowsPtcRunner.Kernel.ReplSession- Stateful direct evaluation wrapperPtcRunner.Lisp.Eval- Evaluates programs with user_ns (memory) symbol resolution
Float Precision
When :float_precision is set, all floats in the result are rounded to that many decimal places.
This is useful for LLM-facing applications where excessive precision wastes tokens.
# Full precision (default)
{:ok, step} = PtcRunner.Lisp.run("(/ 10 3)")
step.return
#=> 3.3333333333333335
# Rounded to 2 decimals
{:ok, step} = PtcRunner.Lisp.run("(/ 10 3)", float_precision: 2)
step.return
#=> 3.33Resource Limits
Lisp programs execute with configurable timeout and memory limits:
PtcRunner.Lisp.run(source, timeout: 5000, max_heap: 5_000_000)Exceeding a limit returns an error result whose Step.fail.reason is
:timeout or :memory_exceeded. Step.fail.details.phase identifies
:setup (bounded grant preparation) or :eval (program execution). Heap
diagnostics also include :limit_bytes, :baseline_bytes, and
:budget_bytes.
Context Filtering
By default, PTC-Lisp performs static analysis to identify which data/xxx keys are accessed
by a program, then filters the context to only include those datasets. This significantly
reduces memory pressure when the context contains large datasets that aren't used.
# Only products is loaded into the sandbox, orders/employees are filtered out
ctx = %{"products" => large_list, "orders" => large_list, "employees" => large_list}
PtcRunner.Lisp.run("(count data/products)", context: ctx)Filtering performs direct lookups for the statically referenced keys; it does not enumerate or copy unrelated collection or scalar grants into the sandbox.
Disable filtering if you need all context available (e.g., for dynamic data access):
PtcRunner.Lisp.run(source, context: ctx, filter_context: false)See PtcRunner.Lisp.DataKeys for the static analysis implementation.
Validate PTC-Lisp source code without executing it.
Parses and analyzes the source, then checks for undefined variables.
Returns :ok if valid, or {:error, messages} with a list of error strings.
Accepts optional keyword options to configure compile-phase limits:
:compile_timeout- Timeout in ms for bounded compile (default: 5000):max_heap- Max heap words for bounded compile (default: 1_250_000):max_program_bytes- Max source size in bytes (default: 1_000_000)
Examples
iex> PtcRunner.Lisp.validate("(and (map? data/result) (> (count data/result) 0))")
:ok
iex> PtcRunner.Lisp.validate("(and (map? foo) true)")
{:error, ["foo"]}
iex> PtcRunner.Lisp.validate("(let [x 1] (> x 0))")
:ok