A tiny AI-agent harness in Elixir, talking to a local Ollama. It's an Elixir port of Roger Oriol's "Build a Basic AI Agent From Scratch" series (part 1, part 2: tools, part 3: long-task planning), rebuilt on OTP primitives. All three stages are here:
- Part 1 — the loop: an LLM connection + message history + a loop
- Part 2 — tools: bash / read / write / edit / glob / grep / webfetch
- Part 3 — planning: a scratchpad + a to-do list so it can plan long tasks
ollama serve # and: ollama pull llama3.1 (any tool-calling model)
git clone https://gist.github.com/0d60ce88c9430b79581a5e2e12b0ffdb.git mini-agent
cd mini-agent
elixir run.exsConfig via env vars: AGENT_MODEL (default llama3.1), OLLAMA_URL
(default http://localhost:11434/v1/chat/completions).
| File | Module | What it is |
|---|---|---|
run.exs |
— | Entry point: installs deps, loads the modules, starts the REPL |
tools.exs |
Harness.Tools |
Action tools + planning tools (scratchpad, to-do) |
todo.exs |
Harness.Todo |
The to-do list rules, as pure functions over a list |
llm.exs |
Harness.LLM |
The Ollama connection (OpenAI-compatible endpoint) |
agent.exs |
Harness.Agent |
The GenServer — one isolated process per session — and the loop |
cli.exs |
Harness.CLI |
A tiny REPL front end |
todo_board.exs |
Harness.TodoBoard |
A to-do list shared by many agents — a GenServer (see below) |
shared_board.exs |
— | Runnable demo: many concurrent writers on one shared board (no LLM) |
Who owns what, and who talks to whom. Blue = a live process (a GenServer or a supervised Task); green = a plain stateless module (code, no process of its own); red = an external system.
flowchart TB
user(["You (terminal)"])
subgraph mainproc["main process"]
cli["Harness.CLI<br/>REPL loop"]
end
subgraph sess["one agent session — its own processes"]
agent["Harness.Agent · GenServer<br/>owns: messages · scratchpad · todos"]
sup["Task.Supervisor<br/>one per agent"]
task["tool tasks<br/>serial + parallel"]
end
llm["Harness.LLM"]
tools["Harness.Tools<br/>dispatch + impls"]
todo["Harness.Todo<br/>pure rules"]
board["Harness.TodoBoard<br/>GenServer · shared"]
ollama[("Ollama<br/>port 11434")]
world[("bash · files · web")]
user -->|"types a goal"| cli
cli -->|"GenServer.call {:chat, input}"| agent
agent -->|"in-process call"| llm
llm <-->|"HTTP JSON"| ollama
agent -->|"async_nolink / async_stream_nolink"| sup
sup --> task
task -->|"Harness.Tools.run/3"| tools
tools -->|"action tools"| world
tools -->|"private todos"| todo
tools -->|"GenServer.call (shared mode)"| board
board --> todo
board -.->|"broadcast {:todo_board, items}"| agent
agent -->|"final answer"| cli
classDef proc fill:#dbeafe,stroke:#2563eb,color:#111;
classDef mod fill:#dcfce7,stroke:#16a34a,color:#111;
classDef ext fill:#fee2e2,stroke:#dc2626,color:#111;
class agent,sup,task,board proc;
class cli,llm,tools,todo mod;
class ollama,world ext;
Two things to read off it: the conversation never leaves the Harness.Agent
process (state ownership), and every tool runs out in a supervised Task, not
in the agent — so a hung or crashing tool can't take the session down.
One turn, step by step — the loop runs until the model stops asking for tools:
sequenceDiagram
actor U as You
participant C as Harness.CLI
participant A as Harness.Agent
participant L as Harness.LLM / Ollama
participant S as Task.Supervisor + tasks
participant T as Tools / TodoBoard
U->>C: goal
C->>A: GenServer.call {:chat, input}
loop until no more tool_calls
A->>L: messages + tool schemas
L-->>A: assistant message
alt model asked for tools
A->>S: spawn — serial threaded, parallel fanned-out
S->>T: Harness.Tools.run/3
T-->>S: result strings
S-->>A: results, collected by id
A->>A: append tool results, loop
else final answer
A-->>C: text
end
end
C-->>U: prints answer
A harness is "supervise a long-lived loop that runs untrusted, slow, crash-prone work." That's exactly what the BEAM was built for. Versus the Python original:
-
Each agent session IS a process — a GenServer (
Harness.Agent). Its conversation, scratchpad and to-do list are state owned by that one process. The Python version keeps the scratchpad/to-do as module-level mutable globals, so one OS process can only ever run one conversation. Here,start_linkagain and you have a second agent — isolated, with its own memory, running concurrently. Spawn thousands; the BEAM schedules them. Messages to a process are serialized, so there are no data races, and the process can be named, supervised, restarted, or told to stream progress to a UI. -
Tools run under the agent's own
Task.Supervisor. Each tool call is a supervised task with a hard timeout (Task.shutdown/on_timeout: :kill_task) and no link back to the agent (async_nolink), so a tool that hangs (a runawaybash) or crashes comes back as an error string the model can replan around — it never takes the agent down. The Python loop only catchesTypeError; anything else, or asubprocesswith no timeout, wedges or kills the agent. -
State is immutable, so sharing it is safe. The loop threads one map and returns a new one; nothing mutates underneath you. That's what lets #1 hold thousands of isolated agents, and lets the parallel tool calls in #4 run without locks.
-
Independent tool calls run in parallel. When the model fires off several reads/greps/fetches in one turn, they run concurrently via
Task.Supervisor.async_stream_nolink; the few tools that mutate the agent's own state are serialized so their results can be threaded. Control flow stays plain — pattern matching + recursion, one function head per tool.
A process earns its existence when it has concurrency, an independent
lifecycle, or shared access by multiple processes. The agent has all three.
Inside one agent's sequential loop, the scratchpad (a String) and to-do list (a
[map]) have none of them — only the agent ever touches them, they die with the
agent, and tool execution is already crash-isolated by the Task wrapper. Wrapping
a value you access sequentially in a read/write process is the classic
"process as a data structure" anti-pattern; here they're just the agent's owned,
immutable state.
That flips the moment the state is shared (multiple sub-agents collaborating on one to-do board), must outlive the agent (persistence), or must stream to a LiveView. Then it genuinely deserves its own process (or ETS/DB) — and the message passing becomes the point, not boilerplate. Which is exactly the next file.
Harness.TodoBoard is the version that earns a process, because a board shared by
many agents has all three properties a private to-do list lacks:
- concurrency — many agents append/update at once; the GenServer serializes every change, so no lost updates and no torn reads;
- shared access — every agent holding the pid sees the same board;
- own lifecycle — it can outlive any one agent, be supervised, and broadcast
changes to watchers (a coordinator, a LiveView) via
subscribe/2.
flowchart LR
subgraph agents["many agent sessions"]
a1["Harness.Agent #1"]
a2["Harness.Agent #2"]
a3["Harness.Agent #3"]
end
board["Harness.TodoBoard<br/>one shared GenServer"]
watcher(["subscriber<br/>coordinator / LiveView"])
a1 -->|"append · update · list"| board
a2 -->|"append · update · list"| board
a3 -->|"append · update · list"| board
board -.->|"broadcast {:todo_board, items}"| watcher
classDef proc fill:#dbeafe,stroke:#2563eb,color:#111;
class a1,a2,a3,board proc;
The rules still live in Harness.Todo (pure) — the board only adds the
concurrency/sharing/notification layer. Same to-do tool interface as before; the
agent just picks the backend at start:
{:ok, board} = Harness.TodoBoard.start_link()
agents = for _ <- 1..3, do: elem(Harness.Agent.start_link(board: board), 1)
Task.async_stream(agents, &Harness.Agent.chat(&1, "...your slice of the goal..."))
IO.puts(Harness.TodoBoard.list(board, true)) # the merged planSee it without an LLM (instant — four concurrent writers hammering one board):
elixir shared_board.exsThis is the heuristic in action: the same to-do data is private state in the single-agent example and a process here — driven purely by whether it's shared, not by a reflex to wrap everything in a GenServer.