# PTC-Lisp Playground
```elixir
# For local dev: run `mix deps.get` in the project root first
repo_root = Path.expand("..", __DIR__)
deps =
if File.exists?(Path.join(repo_root, "mix.exs")) do
[{:ptc_runner, path: repo_root}]
else
[{:ptc_runner, "~> 0.9.0"}]
end
Mix.install(deps, consolidate_protocols: false)
```
## Introduction
PTC-Lisp is a small, safe subset of Clojure designed for Programmatic Tool Calling. Programs run in sandboxed BEAM processes with resource limits (1s timeout, 10MB memory).
**Key concepts:**
* `->>` threads data through a pipeline (like Elixir's `|>`)
* `:keyword` accesses map fields (converted to string keys internally)
* `where` builds predicates for filtering
* `tool/tool-name` calls external tools
## Basic Example
Filter expenses and sum amounts:
```elixir
tools = %{
"get-expenses" => fn _args ->
[
%{"category" => "travel", "amount" => 500},
%{"category" => "food", "amount" => 50},
%{"category" => "travel", "amount" => 200}
]
end
}
program = ~S|(->> (tool/get-expenses) (filter (where :category = "travel")) (sum-by :amount))|
{:ok, step} = PtcRunner.Lisp.run(program, tools: tools)
IO.puts("Travel expenses: #{step.return}")
```
### Step-by-step breakdown
1. `(tool/get-expenses)` - calls the tool, returns list of expense maps
2. `(filter (where :category = "travel"))` - keeps only travel expenses
3. `(sum-by :amount)` - sums the amount field
## PTC-Lisp Extensions
The example above uses `where` and `sum-by`, which are PTC-Lisp extensions not found in standard Clojure. These make common filtering and aggregation patterns more concise.
In standard Clojure, the same logic would be:
```elixir
program = ~S|(->> (tool/get-expenses) (filter #(= (:category %) "travel")) (map :amount) (reduce +))|
{:ok, step} = PtcRunner.Lisp.run(program, tools: tools)
IO.puts("Travel expenses (Clojure style): #{step.return}")
```
**Key PTC-Lisp extensions:**
| Extension | Clojure Equivalent | Purpose |
| ------------------------ | ------------------------------ | ------------------------------ |
| `(where :field = value)` | `#(= (:field %) value)` | Build predicates for filtering |
| `(sum-by :field coll)` | `(reduce + (map :field coll))` | Sum a field across collection |
| `(avg-by :field coll)` | Manual calculation | Average a field |
| `(all-of pred1 pred2)` | `#(and (pred1 %) (pred2 %))` | Combine predicates with AND |
| `(any-of pred1 pred2)` | `#(or (pred1 %) (pred2 %))` | Combine predicates with OR |
These extensions are designed for LLM code generation - they reduce syntax errors and make intent clearer.
## Key Convention
Tools receive and return **string-keyed maps** (like JSON):
```elixir
# Tool data uses string keys
%{"category" => "travel", "amount" => 500}
```
PTC-Lisp's FlexAccess makes `:keyword` access work transparently with string-keyed data:
```elixir
# These all work on string-keyed maps:
(:category item) # => "travel"
(where :category = "travel") # matches %{"category" => "travel"}
(sum-by :amount expenses) # sums the "amount" field
```
**Why string keys?**
* Prevents atom table exhaustion from LLM-generated code
* Matches JSON conventions (external APIs, Phoenix params)
* Tools pattern match on string keys: `fn %{"id" => id} -> ... end`
## Working with Variables
Use `let` to bind intermediate results:
```elixir
program = ~S"""
(let [expenses (tool/get-expenses)
travel (filter (where :category = "travel") expenses)]
{:count (count travel)
:total (sum-by :amount travel)
:avg (avg-by :amount travel)})
"""
{:ok, step} = PtcRunner.Lisp.run(program, tools: tools)
step.return
```
## Error Handling
PTC-Lisp provides helpful error messages with hints:
```elixir
# Missing operator in where clause
bad_program = ~S|(filter (where :status "active") items)|
case PtcRunner.Lisp.run(bad_program, tools: %{}) do
{:error, error} -> IO.puts("Error: #{inspect(error)}")
{:ok, step} -> step.return
end
```
```elixir
# Type error - sum-by needs a collection
bad_program = ~S|(sum-by :amount "not a list")|
case PtcRunner.Lisp.run(bad_program, tools: %{}) do
{:error, error} -> IO.puts("Error: #{inspect(error)}")
{:ok, step} -> step.return
end
```
## Data Transformation
Transform and join data from multiple sources:
```elixir
tools = %{
"get-users" => fn _args ->
[
%{"id" => 1, "name" => "Alice", "email" => "alice@example.com"},
%{"id" => 2, "name" => "Bob", "email" => "bob@example.com"}
]
end,
"get-orders" => fn _args ->
[
%{"user-id" => 1, "product" => "Laptop", "total" => 1200},
%{"user-id" => 2, "product" => "Mouse", "total" => 25},
%{"user-id" => 1, "product" => "Keyboard", "total" => 150}
]
end
}
program = ~S"""
(let [users (tool/get-users)
orders (tool/get-orders)
high-value (filter (where :total > 100) orders)]
(->> high-value
(mapv (fn [order]
(let [user (find (where :id = (:user-id order)) users)]
{:customer (:name user)
:product (:product order)
:total (:total order)})))))
"""
{:ok, step} = PtcRunner.Lisp.run(program, tools: tools)
step.return
```
## Advanced: Grouping and Aggregation
Group expenses by category and compute totals:
```elixir
expenses_tools = %{
"get-expenses" => fn _args ->
[
%{"category" => "travel", "amount" => 500},
%{"category" => "food", "amount" => 50},
%{"category" => "travel", "amount" => 200},
%{"category" => "food", "amount" => 75}
]
end
}
program = ~S"""
(let [expenses (tool/get-expenses)
by-category (group-by :category expenses)]
(->> (keys by-category)
(mapv (fn [cat]
{:category cat
:total (sum-by :amount (get by-category cat))
:count (count (get by-category cat))}))))
"""
{:ok, step} = PtcRunner.Lisp.run(program, tools: expenses_tools)
step.return
```
## Learn More
* [PTC-Lisp Specification](https://hexdocs.pm/ptc_runner/ptc-lisp-specification.html) - Complete language reference
* [SubAgent Getting Started](https://hexdocs.pm/ptc_runner/subagent-getting-started.html) - Build LLM-powered agents
* [LLM Agent Livebook](ptc_runner_llm_agent.livemd) - Interactive agent example