Engineering · Agents on the BEAM
The agent wrote its own tool. On the BEAM it gets to keep it.
Jamie Beach built a 100-line Lisp agent that writes its own tools mid-chat. Neat trick, with two problems he flags himself. Run the same thing on the BEAM and both problems go away: the tool the model writes becomes a real module that survives crashes and every agent can call.
The whole shape in one picture:
flowchart TD
P["Problem to solve"] --> A["Ask the LLM: not for an answer,
but a reusable snippet"]
A --> S["LLM returns a snippet
(code is data)"]
S --> R["Run it, get a result"]
S --> M["Compile it to a module,
hot-load into the node"]
M --> K["Kept in the code table"]
K --> U["Any agent reuses it later"]
U -. "next problem" .-> A
Answer with a snippet, not an answer
Beach’s agent has exactly one tool: “run this code.” So when you ask it something it doesn’t answer, it writes a snippet and runs it. Ask for the 30th Fibonacci number and it writes the function and calls it.
You’ve seen this already, even outside Lisp. Hand Claude Code or ChatGPT’s code interpreter a hard question and it’ll often write and run a little Python script instead of answering from memory. Same idea. The model’s best move is usually runnable code, not the answer itself.
Lisp adds one thing: the snippet is also data, just a plain list. So you can do two things with it: run it,
and keep it (it’s text in the message log, re-run next session). Beach calls that “skills are
memories”: the agent writes itself a brave-search function once, then re-hydrates it from
its own transcript later.
Two problems
Beach names both:
- Unsafe. It’s
evalon arbitrary model output. Sandbox only. - It doesn’t stick. The function dies with the process. Only the text in the
transcript survives, and it has to be re-
evald every boot. Lose the process to a crash (someone in the HN thread hit exactly this) and you’re re-hydrating from memory again, hoping it still evals.
Both come from the same thing: one big mutable Lisp image you keep scribbling functions into. Fine for one agent. I wanted hundreds (the crowd), so I kept the trick and swapped the runtime under it.
The swap: snippet becomes a module
On the BEAM you don’t scribble into a shared image, you load modules, and you can compile and load one
while the system is running (hot code loading). In LFE
(Lisp on the BEAM) the snippet is still a plain list, so the trick is unchanged. You just compile it and
load it instead of evaling it:
(defun install-skill (source)
;; source is the text the model handed back
(let* ((`#(ok ,forms) (lfe_io:read_string source)) ; text to a list (code is data)
(`#(ok (#(ok ,mod ,bin))) (lfe_comp:forms forms '()))) ; that list to a real BEAM module
(code:load_binary mod "nofile" bin) ; load it into the running node
mod))
That’s the LFE version of Elixir’s Code.compile_string. The output isn’t a
function stuck in one process, it’s a module in the node’s code table.
This compiles and runs on LFE 2.2 / OTP 27. Full file, ~70 lines.
Why that’s better
The module lives in the node, not in the agent that wrote it. Three things fall out of that:
- It sticks. Loaded until the node stops. No re-
eval, and a crash doesn’t lose it, because the module isn’t in the process that crashed. - Everyone can use it. Any agent on the node can call it, not just the one that wrote it. Across the cluster the crowd already runs, that’s every machine.
- A bad snippet can’t sink the system. Each agent is its own supervised process, so if the model writes garbage and it crashes, that one process dies and restarts, nothing else notices.
Here’s the middle point as an actual run. A bot is just a process that runs whatever skill it’s
handed (full code in the file). alice installs the skill; bob, a different process
that never installed anything and never restarts, calls it before and after:
;; 1. bob calls the skill BEFORE it exists
(ask bob 'web-search 'query (list "beam vs node"))
;; 2. alice installs the module the model wrote, at runtime
(install-skill "(defmodule web-search (export (query 1)))
(defun query (q) (++ \"results for: \" q))")
;; 3. alice runs it 4. bob runs the SAME module, never installed, never restarted
(ask alice 'web-search 'query (list "beam vs node"))
(ask bob 'web-search 'query (list "beam vs node"))
1. bob, before it exists -> {undefined,undef}
2. alice installs -> hot-loaded module: 'web-search'
3. alice runs it -> {ok,"results for: beam vs node"}
4. bob runs the SAME one -> {ok,"results for: beam vs node"}
bob never installed the skill or restarted. one agent wrote it, every agent could use it.
One caveat the BEAM doesn’t fix: it’s still model-written code. You wouldn’t load it unattended in production. But the snippet is a plain list, so you can inspect it before loading: model proposes, you (or a check) approve, then it loads. After that the runtime contains it.
It’s the runtime, not Lisp
Honest version: none of this is really about Lisp. The same thing works in plain Elixir
(Code.compile_string, :code.load_binary, supervised processes) because it all
comes from the BEAM, not the language. LFE just makes the snippet a literal list, a nice rhyme with
Beach’s Lisp and nothing more.
The tell is trying it off the BEAM. In Rust you can shell out to a compiler and dlopen the
result, but “a bad snippet crashes one agent, not the system” is the part you’d rebuild by
hand (separate OS processes, or WASM). That containment is basically the reason the BEAM exists.
So: skills as modules, not memories. The agent writes them, the runtime keeps them.
Links
- An Agent in 100 Lines of Lisp, Beach’s original.
- The crowd post: hundreds of these agents on the BEAM, no framework.
- LFE: Lisp on the BEAM.
- Hot code loading: swapping a module in a running system.