Anthropic’s blog post about programmatic tool calling pointed to a better way to use AI agents. Instead of calling tools in a loop, the agent becomes a programmer: it writes the function calls and the logic in a language of its choice, and the runtime executes them.
This is the story of how I went down a rabbit hole of sandboxing, interpreter design, and Lisp (a programming language from 1958), and came out the other side with lisptc, a small Lisp built specifically for AI agents: interpreter, LSP, formatter, and REPL.
But the tooling is only half the story.
My idea is that the LLM is not the agent. It’s one module in a larger system, and the Lisp is the glue that holds all these modules together. Hold onto that; I’ll come back to it.
What programmatic tool calling is
The usual way an agent uses tools is a call-and-wait loop. The model emits a single tool call as structured JSON, the runtime executes it and hands back the result, the model reads that result and emits the next call. That’s one round trip per step, and every intermediate value has to pass back through the model’s context along the way.
Programmatic tool calling flips the whole arrangement. Instead of emitting one call at a time, the model writes a program: a snippet of ordinary code in which the tools show up as functions it can compose, loop over, branch on, and assign to variables. The runtime runs the whole snippet and returns only what the program chose to surface. The model stops being the thing that dispatches each call and becomes the thing that writes the logic around them.
That shift is the entire premise of this project, so it’s worth being concrete about why it’s such an improvement.
For those who are unfamiliar with this subject, here is a video that explains PTC in more detail:
Why programmatic tool calling wins
The advantages compound quickly. There’s no JSON-schema boilerplate wrapping
every call, and no giant system prompt describing every tool up front. You get
real control flow: call a tool inside a for loop instead of relying on the
model to fire off N separate calls by hand. Intermediate data lives in
variables and gets passed around without ever loading it back into the
model’s context. And the agent can pull from the huge ecosystems of libraries
and SDKs already out there, not just the tools you hand it.
The only serious downside I can think of is security. The code the agent executes can be malicious in plenty of ways: the user might want it to be, a prompt injection might slip in, or the agent might just write code that never stops eating host resources.
The untrusted-code rabbit hole
That’s the well-known problem of untrusted code execution, and it drops you straight into a rabbit hole of decades of work on sandboxing and isolation. It’s still a standing problem, only ever mitigated, never solved, and every current solution piles a lot of complexity onto your stack.
The root difficulty is this: trying to limit the power and access of a general-purpose programming language is a hard task, and there is always a workaround. This is what Cloudflare, e2b, and exe.dev try to provide.
But there’s another way to get exactly what you want. Instead of fighting to strip capabilities out of an existing language, you build a language from the ground up that never had the access you didn’t want in the first place.
When Vercel announced zerolang, a language destined for AI agents, I was excited. I hoped they’d optimized for prompt usage, designed the whole thing around zero-trust permissions, and baked in memory and MCP support. Instead it was a flop: a side project the marketing team made a big deal of.
Nobody I knew of had built what I needed, so I stepped out of my comfort zone and started reading about interpreters and programming-language design. And waiting for me at the entrance was the perfect language for the job. Lisp!
Enter Lisp
First developed in 1958 for AI research, its ideas went on to influence most of today’s programming languages. The questions John McCarthy (creator, or maybe discoverer of Lisp) was wrestling with back then, when the whole point was symbolic reasoning for machines, map almost perfectly onto what an agent language needs now:
- What if a program were written in the very same form as the data it operates on, so that code and data became interchangeable?
- What is the smallest handful of irreducible primitives from which every other operation can be built?
- Could a language be simple enough to describe its own evaluation, to act as its own interpreter?
- And what if you threw out elaborate syntax altogether, so that a program was just a list?
Sixty-odd years later, each answer earns its keep here. Code-as-data means a program is an ordinary structure the agent can build, read back, and reshape, and that reaches the language itself: a pattern it keeps spelling out by hand becomes a single form, so the same task costs fewer tokens every time it comes up again. Macros are the obvious payoff, but the deeper one is that the agent can bend the language to fit the job instead of the other way around. A handful of primitives keeps the language small enough to reason about completely and to pin down with a compact grammar. A self-describing core made the interpreter, LSP, and formatter almost embarrassingly simple to build. And when a program is just a parenthesized list, there’s very little syntax for the model to fumble and very little to constrain. The REPL workflow that grew out of all of it is the final fit: an interactive, feedback-driven loop the model was already trained on.
I found Make a Lisp (mal), an open-source project for building Lisp interpreters across languages: C, JS, Python, Go, you name it. The C implementation was around 500 lines. I was in awe. I realized I could fit the entire interpreter into a system prompt, so the agent could learn the language inside out.
So that’s what I did. I cloned a TypeScript implementation and, with Claude Code,
went on modifying it into an in-memory REPL that I hooked up to a
PI agent. The result is lisptc: a Lisp
dialect and interpreter, plus an LSP, a formatter, and a REPL, all aimed at
agents. Scripts carry the .ptc extension. You can find it here.
It’s real Lisp underneath: closures, macros, exact bigint arithmetic, the lot.
(defun classify (n)
(cond ((< n 0) 'negative) ((= n 0) 'zero) (t 'positive)))
(mapcar classify '(-3 0 7)) ; => (negative zero positive)
Macros are where homoiconicity stops being a fancy theoretical property and starts paying rent. A program is a list, so the agent can write code that writes code, and it does it in the session it’s already working in. Say it keeps repeating the same navigate-then-look-at-the-page dance a dozen times over. Instead of repeating itself, it can teach the language the pattern:
(defmacro visit (url &rest body)
`(progn
(playwright/browser_navigate :url ,url)
(let ((page (playwright/browser_snapshot)))
,@body)))
(visit "https://hyko.ai" (grep page "pricing"))
From then on visit is a form like any other, with the same LSP support as
everything else in scope. No AST library, no parser, no build step, just a list
that describes another list. The agent isn’t calling an extension API here, it’s
using the language the way the language was always meant to be used.
This is also, I think, the right way to write agent memory. Today memory usually means a pile of markdown files the agent greps through and reads back into its context. That’s a filing cabinet. It stores facts and nothing else, and every use of it costs context.
A macro is a memory too, and a far better kind. It persists, it evaluates, it can reach for other memories, it can have side effects. The agent can write code that runs over its memories, which are themselves code, to produce a new memory, which is also code. Declarative memory, the things it knows, and procedural memory, the things it knows how to do, stop being two separate systems stapled onto a model and become the same thing: lists the interpreter can already read, store, and run.
Along the way I needed a language server, which turned out to be fairly easy with vscode-languageserver (despite the name, it works with any modern editor), and a formatter, where Topiary had my back with a spec-driven one. Everything got packaged into a Nix flake, and I was genuinely impressed by how easy it all was, all thanks to the genius minimalism of Lisp’s design.
But why not just use TypeScript?
Fair question, and one I asked myself for a while before writing a single line. TypeScript is right there. Every model writes it fluently, npm has a package for everything, and the sandboxing story, while unsolved, is at least well trodden. Inventing a language is the kind of thing you should have to justify.
Here is the comparison as I see it, running an agent’s programs in a sandboxed TypeScript runtime versus running them in lisptc:
| ts in sandbox | lisptc | |
|---|---|---|
| Security model | Deny-list. You start from a language that can do anything and take capabilities away, hoping you found them all. | Allow-list. Nothing exists in the runtime unless I put it there, so there is nothing to strip out. |
| Cost of isolation | V8 isolates, containers, or a microVM per run, plus the ops burden that comes with them. | The interpreter is the boundary. No extra infrastructure. |
| Size of the language | Hundreds of pages of spec and decades of accumulated edge cases. | The whole interpreter fits in a system prompt. |
| How the model learns it | It relies on whatever it absorbed during training, and on you describing your API in prose. | It reads the actual interpreter, so it knows the language exactly, not approximately. |
| Constrained decoding | A grammar for TypeScript is enormous and unusable in practice. | A full GBNF grammar in 10 lines, so an open model physically cannot emit invalid syntax. |
| Code as data | Extending the language means an AST library, a parser, and a lot of ceremony. | A program is a list, so the language extends itself. The agent defines a macro mid-session to collapse a pattern it keeps repeating, and the new form is indistinguishable from a builtin. |
| Tool calls | An SDK, a client, and glue code per server. | Tools are ordinary globals: (playwright/browser_navigate :url "...") |
| Ecosystem | npm. Nothing I build competes with this. | What I wrote, plus whatever MCP servers expose. |
| Model fluency | Native. This is what the training data is made of. | Rusty, and the parentheses genuinely worried me. |
| Who owns the runtime | You rent it. Compression, disclosure, and tooling are things you add around a runtime someone else defined. | I own it, so the tricks land inside the language. Reads are grep-able and paginated by construction, and loading an MCP server teaches the LSP about it, so tools arrive with completion and documentation on the spot. |
The two rows before the last are real losses and I am not going to pretend otherwise. The ecosystem gap stings least, because MCP absorbs most of what an agent actually reaches for, and a server is easier to wrap than a library is to sandbox. The fluency gap is the one I lost sleep over, and I’ll come back to it further down.
What owning the whole stack buys you
Here’s the part I didn’t fully expect. Because I owned the interpreter and the REPL end to end, capabilities that are expensive add-ons everywhere else came almost for free. Each one below is less a feature I built than a property that fell out of the design.
Above in the illustration, the prelude is the file the REPL starts from: procedures the agent can reuse without writing them again, plus the goal it’s working toward and the plan it came up with.
Grammar-constrained output
The best part is that I can force the model’s output to conform to the Lisp
syntax. llama.cpp uses the GBNF
format: you describe the language’s grammar and generation rules, pass it in with
your API call, and the model can only emit a token that satisfies the grammar.
Not every provider exposes this passthrough, and I can only name a few that do;
Fireworks AI
is one. Maybe at some point I’ll have to self-host these models.
The REPL loop
I hadn’t touched a Jupyter notebook since I switched careers from AI engineer to software engineer, and I’d forgotten how amazing the REPL model is. You debug and test code as you write it; the REPL keeps the context and gives you feedback.
For an AI agent this is fantastic. The agent gets instant feedback, and it was trained on exactly this kind of execution, with every notebook in its training data working this way. The minimal-by-default, easily-extendable nature of the PI agent framework meant I had a chat TUI wired to my REPL from the get-go, with the custom formatter and LSP alongside it: an adequate workbench for tearing these scripts apart and reassembling them.
MCP as a native primitive
Another feature that was really easy to implement is native MCP integration. You load an MCP server, stdio or online, and every tool it exposes becomes a plain function. You get discoverability without asking for it: search predefined servers, or look for a tool inside one already loaded.
;; discover a server in the bundled toolkit, load it, and call its tools
;; (no JSON schema, no glue code)
(search-mcps "browser") ; => ranked matches, best first
(await (load-mcp "playwright")) ; async: returns a job; await installs the bindings
;; every tool is now an ordinary global binding named <server>/<tool>,
;; called with native keyword syntax
(playwright/browser_navigate :url "https://hyko.ai")
(princ (playwright/browser_snapshot)) ; accessibility tree, paginated by default
Notice the await: because jobs can be scheduled or awaited, a server can load
in the background while the agent keeps working.
Because the REPL and the editor share one environment, loading a server changes
what the editor knows. The moment (await (load-mcp "playwright")) returns, the
LSP has the new bindings: completion on playwright/, signatures on every tool,
documentation on hover. The agent doesn’t need a manual for a server it just
loaded, it can ask the language itself. That’s progressive disclosure as a
native primitive rather than an afterthought bolted on later, because the tools
become visible at exactly the moment they become available.
The same shared environment is what makes the REPL more than a convenience. State survives between turns, so the agent can load a server once, poke at a tool to see what it actually returns, keep the useful result in a variable, and build on it later without paying for any of it twice.
Context compression by construction
Another perk of owning the interpreter and the REPL is that you can build in context compression from the get-go. Each variable can only be read with grep-like commands, and the results come paginated by default, so the LLM reads exactly what it needs and nothing more.
This is especially helpful with browser automation over the Playwright MCP. Most of the time a page snapshot arrives bloated with styling, JavaScript, and HTML, which pollutes the context and drags you into the 30% “dumb zone” within a few turns, if not one. Paginated, grep-able reads keep that bloat out.
There’s also a read-only variable holding the entire conversation history that the agent can pull input from. Think of a long number an open-source model would surely hallucinate halfway through if it had to retype it.
The fear: parentheses
One of the biggest fears I had with this whole approach was, drum roll… the parentheses. I was afraid open models would always mess up Lisp’s syntax, given that a big portion of their training data is JS and Python.
It turned out fine. I was impressed by how well the proprietary models did with the whole interpreter sitting in the system prompt, and by how well the open models did when their output was constrained by the grammar. At first Claude made at least one mistake before fixing its calls to the Lisp REPL, but with the interpreter in the system prompt, things were smooth. I built the whole project with coding agents, and the tests turned out to be straightforward: you write Lisp code and expect it to do what it says.
The LLM is not the agent
Which brings me back to the claim I opened with. The last missing piece is a cognitive architecture that combines the power of LLMs with the symbolic nature of the language: a separate memory module that extends the interpreter.
The LLM is not the agent. It’s part of a system where every module does one job toward a shared goal. The best move is to leave the imaginative, flexible parts to the LLM, and the rest to the symbolic system, the Lisp. Just like the brain has two hemispheres.
I deliberately prefer “cognitive architecture” over “harness.” Harness is the new kid on the block; cognitive architecture goes back decades, a term backed by real research.
Still a work in progress
None of this is finished. The immediate next step is to benchmark the neuro-symbolic architecture against its harness-style counterparts, but the roadmap runs a lot further than that. Macros are the shallow end of code-as-data, and I want to see how deep it goes: an agent that reads back its own programs as data, spots the shape it keeps rewriting, and folds it into a form it can reuse, building a vocabulary for a task as it works through it. Generative UI is another thread worth pulling on: the agent renders its own interfaces and visualizes the prelude and a run as they happen. Declarative agents are the bigger one, described by what they should achieve, all in Lisp (just like how NixOS built the Nix language for OS configuration, you can think of lisptc as the declaration of orchestration of agents).
The whole thing should also grow in a cloud-native setup, a distributed file system the agents can share, and Git woven in so that every change an agent makes is versioned, reviewable, and reversible.
But honestly, this is just the tip of the iceberg. At this point, anything you can imagine needing to build an agent around an LLM can be implemented neatly in this system, and the LLM finally gets to do only the part it’s actually good at.