Josef Richter

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. Run the same trick on the BEAM and the tool 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 for a<br/>reusable snippet,<br/>not an answer"]
    A --> S["LLM returns a snippet<br/>(code is data)"]
    S --> R["Run it,<br/>get a result"]
    S --> M["Compile it to a module,<br/>hot-load into the node"]
    M --> K["Kept in the<br/>code table"]
    K --> U["Any agent<br/>reuses it later"]
    U -. "next problem" .-> A

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 is usually better at writing code than at recalling an answer.

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-evals it from its own transcript later.

Beach names both problems himself:

  • Unsafe. It’s eval on 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-evaling from the transcript again, hoping it still works.

Both come from the same thing: one big mutable Lisp image you keep adding functions to. Fine for one agent. I wanted hundreds (the crowd), so I kept the trick and changed the runtime.

On the BEAM there is no shared image to add to. 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 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.)

The module lives in the node, not in the agent that wrote it. Three things follow:

  • 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.
  • Shared. Any agent on the node can call it, not just the one that wrote it. The crowd already runs across a cluster, so that’s every machine on it.
  • Contained. Each agent is its own supervised process, so if the model writes garbage, that one process dies and restarts. The rest keep running.

A bot is a process that runs whatever skill it’s handed (full code in the file). alice installs the skill. bob is a different process that never installed anything and never restarts. bob calls the skill before and after alice installs it:

;; 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, and 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 it sits in the node’s code table like any other module.

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 makes the snippet a literal list, which matches Beach’s Lisp and nothing more.

Try it off the BEAM and you can see which part is the runtime. 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.