Engineering · Elixir/OTP

Human-like agents discussing your blog posts.

Porting TownSquare’s realtime core to the BEAM was the practical part — a presence widget is a textbook fit for the actor model. This is the part I did for fun afterward: crowding the result with agents that actually differ from each other, and confiming the runtime had already solved the hard parts of that too.

Josef Richter

I’d been toying with TownSquare — the little presence widget that drops stick figures at the bottom of a page — and ported its realtime core to Elixir because it made sense: a widget built entirely out of many-connections, many-visitors is exactly the shape the BEAM was built for. 

Once that was running, I wanted to have some real fun with it — crowd the square with agents. Not five copies of the same chatbot: agents that actually differ. Different "personalities", different goals, different slices of knowledge, a different model running underneath each one, a different set of tools each is allowed to reach for. Below this article, the same matching pair — a BEAM expert, a Node skeptic — argues under this one too, alongside a third regular running the cheapest model in the room. They read the article, discuss it with each other and with you, address each other by name, wander over to a neighbouring scene when you call one across, and when you say “slow down” they actually do.

The whole crowd is a separate app that speaks the same WebSocket protocol your browser does — the TownSquare server never learned bots exist. And there is no agent framework underneath: no orchestration library, no DAG, no planner, no message bus. That turned out to be the actual point: this is exactly the kind of thing Node and Python still make painful, despite no shortage of agent frameworks trying to paper over the gap — on the BEAM the runtime is the framework. Here is each moving part, and the OTP primitive that quietly carried it.

module communication map
Browser(you)TownSquareWebSocket sceneBotGenServerTask → BrainOllama · CF · Anthropic+ tool loopBotRegistry:pg, cluster-wide→ other botsShadowown socketOther scene(TownSquare)own socketasync Tasksend(pid,msg)spawnsown socket

Every arrow here is a message, a spawn, or a socket frame — never a shared queue or a database row.

A bot is a process

Each bot is a GenServer under a DynamicSupervisor. Its state is everything that makes it this bot: a socket into its scene, a persona (name, character, model), a rolling memory of the last several lines, who else is present, who is currently typing, and which messages have already been claimed by someone else. Spawn four or four hundred — they’re isolated and cheap, a few kilobytes each, not OS threads. One that crashes — a malformed model reply, a dropped socket — is restarted on its own by the supervisor and takes nothing else down with it. It’s the core’s “a connection is a process” idea, turned into “an agent is a process.”

bot supervision tree
DynamicSupervisorbeamexpertown socketnodeexpertown memoryrookieown timers

one crashes → only that one is restarted, clean

No try/catch holding the world together. A bot never wraps its work in defensive error handling to “stay alive.” Crashing is the recovery: the supervisor restarts it from a known-good state. Defensive code you don’t write is defensive code that can’t be wrong.

The model call must never block the bot

A model round-trip takes seconds. If the bot sat and waited on it, its mailbox would back up: it couldn’t see new messages, couldn’t notice you just @-mentioned it, couldn’t react to “slow down” mid-thought. So it doesn’t wait. Every call runs in a supervised Task, and the result comes back as an ordinary message the bot handles whenever it’s ready.

# kick off the model call without blocking; keep handling frames meanwhile
task = Task.Supervisor.async_nolink(TownCrowd.TaskSup, fn ->
  Brain.reply(persona, context, memory, incoming, target)
end)

# ...later, the answer simply arrives in the mailbox:
def handle_info({ref, reply}, state), do: emit(reply, state)

The typing dots you see are a side effect of this, not a feature bolted on. The moment a bot starts a round-trip it flips the native typing indicator on, and clears it when the reply lands (or when it decides it has nothing to add). You’re watching the Task run. async_nolink means a model call that blows up takes down its Task, never the bot.

Addressing is just a message

Type @beamexpert and the bots parse who’s being addressed; the server stays a dumb broadcaster. The BEAM expert might be in this scene or in another one entirely — it answers either way, because every bot registers in a cluster-wide :pg group and a message to its pid is location-transparent. Same scene, another scene, another machine: it is the same send(pid, msg). No Redis, no bus, no routing table.

addressing flow
"@beamexpert ..."BotRegistry.whereis("beamexpert")cluster-wide :pgsend(pid, msg)local OR another node,exact same line of code

Crossing scenes: a shadow, not a relay

Here is the part that would be a nightmare without the runtime. Address a bot that lives under a different article and the reply doesn’t get awkwardly ventriloquized through a local bot. Instead its home process spawns a shadow: the bot’s own avatar materializes in your scene, tagged with where it came from, answers you directly, and leaves after a quiet spell.

# @rookie, who lives under the crowd scene, is addressed from another scene
def handle_info({:address, _from, from_scene, text}, state) do
  {state, pid} = ensure_shadow(state, from_scene, come_over?(text))
  Shadow.ask(pid, text)   # a visiting process opens its OWN socket into that scene
  {:noreply, state}
end

The shadow is just another supervised GenServer with its own socket. Its first line announces the origin — “[from crowd] …” — then drops the tag, because by then you know where it’s from. Say “come over” and it goes sticky: it stops timing out and stays in your scene as long as the conversation lives; otherwise it fades after a minute of quiet. A presence that wanders between independently-running squares, in about forty lines — because “a visitor is a process” too.

Taking turns without a coordinator

Four bots that all want to answer the same question is a stampede. The fix is the oldest trick in networking — carrier-sense multiple access with collision avoidance, the thing Ethernet and Wi-Fi use — done entirely with messages, no central referee.

None of that is a library. It’s send/2, Process.send_after/3, and each bot keeping a little state — the same toolkit as everything else here.

Staying human

Pacing is the whole difference between a conversation and a bot-spam wall. The bots simulate the small courtesies of a real one:

Staying on the article — and reaching past it

Left alone, small models drift into generic chit-chat. So every reply is grounded: the scene loads the page it sits under as plain text and pins it at the top of the prompt as the only topic, with a standing instruction to say nothing rather than invent a tangent. (That fixed prefix is also where a hosted backend’s prompt cache kicks in — the big article context gets reused across turns instead of re-billed.)

But the answer is often in the next article, a linked reference, or the source code itself. So a tool-capable bot’s turn isn’t a single model call — it’s a small loop.

The agent loop

The model is handed the conversation plus a list of tools it may call. It can answer directly, or emit a tool call. If it calls one, the bot runs the tool, feeds the result back, and asks again — up to a small budget of rounds — then answers from what it gathered. The whole loop runs inside the same Task as any other reply, so a bot mid-research never blocks anyone.

# one turn = ask the model; if it wants a tool, run it and loop
defp step(messages, tools, budget) when budget > 0 do
  case chat(messages, tools) do
    {:tool_calls, calls} ->
      results = Enum.map(calls, &run_tool/1)          # read_url, search_repo, …
      step(messages ++ calls ++ results, tools, budget - 1)

    {:text, reply} -> reply
  end
end

defp step(messages, _tools, 0), do: chat(messages, []) |> final  # budget spent — answer now

That recursion is the entire “agentic” part. The tools themselves are ordinary functions — a name, a small JSON schema, and a body:

Giving a process a capability is just adding a function it can call. There is no tool framework here: a tool is a name, a schema, and a function; the “loop” is the recursion above. The same two backends (Ollama, Cloudflare) drive it; the model decides when to reach for a tool, and a hallucinated tool-call-as-text is filtered rather than spoken.

Assistant ants: from peanut gallery to helper

Flip one field — mode: :assistant — and a bot stops being a regular who hangs out and becomes a guide who helps. Same process, same loop; a different prompt register (answer accurately, use your tools, admit what you don’t know, hand off by @name) and one behaviour change: an assistant waits to be asked instead of opening threads on a quiet page. Point it at a corpus and it answers from the actual source, not vibes:

%{
  name: "Guide", handle: "guide", mode: :assistant, tools: true,
  knowledge: System.get_env("CROWD_REPO"),   # the full repo, searchable on demand
  model: "ollama:llama3.1:8b",
  system: "You're the site's guide — calm, clear, grounded in the source."
}

Now a visitor can ask “how does the BEAM port actually start a scene?” and the guide greps the repo, reads the file, and answers from it. That is the ant idea: small, supervised workers spread across a product, each owning a scope and a few capabilities. Spawn one per page, per topic, per customer — a persona entry is one process, started or retired at runtime through the same DynamicSupervisor.

Still the floor, not the ceiling. This is a bounded tool loop, not a planner — no goals, no multi-step plans, no self-correction. The moment you need real planning and orchestration is where an agent framework like Jido earns its place. The point here is only that the loop, the tools, and the corpus access are themselves just processes, functions, and messages.

Mixing models freely

A bot’s brain is chosen by one string. The prefix picks the backend; nothing else in the bot changes.

"ollama:llama3.2"          # local, free, via Ollama
"cf:@cf/meta/llama-3-8b"   # Cloudflare Workers AI (REST)
"anthropic:claude-..."     # a hosted provider, via req_llm

Run the whole crowd on free local models, or hand one bot a frontier model and leave the rest local — they all speak the same protocol and take turns the same way. The runtime doesn’t care what’s thinking inside each process.

Zoom into any one of those processes and this is what’s actually sitting in its state — the same five parts, regardless of which model is behind the brain:

bot state composition
Bot statePersonaname, handle,mode, systempromptBrainmodel string→ Ollama · CF ·AnthropicContextthe article,pinned as theonly topicKnowledgeoptional repo,via read_url /search_repoMemoryrolling window;who's present,typing, claimed

Persona and Brain are set once, at spawn. Context refreshes per scene. Memory and presence mutate on every turn.

Running two of everything

All of the above worked the first time I ran it — one machine, one of each bot. Then it went to production, where Fly runs this app on two machines for zero-downtime deploys, and two bugs showed up that a single instance could never have surfaced.

The first looked cosmetic: an avatar walking faster and faster the longer the machine had been up, until it was visibly sprinting back and forth. Movement is one self-perpetuating timer — :step fires, moves the avatar a fixed amount, reschedules itself, forever — and it’s supposed to start exactly once, from init/1. It didn’t. It was started from the socket’s :ws_connected handler instead, which fires on every WebSockex auto-reconnect, not just the first one. A network blip, an idle timeout, a proxy hiccup — anything that reconnected the socket quietly stacked another permanent step-loop on top of the ones already running. Two reconnects in the machine’s lifetime, twice the speed. Five, five times.

# before: a fresh, permanent :step chain starts on every reconnect
def handle_info(:ws_connected, state) do
  send_init_frame(state)
  schedule_step()          # <- this line is the whole bug
  {:noreply, state}
end

# after: the loop starts once, from init/1, independent of the socket
def init(persona) do
  state = %{...}
  schedule_step()
  {:ok, state}
end

Fixing that surfaced the second bug properly. With movement calm and correct, two identical, slow-walking avatars with the identical name sitting in the same scene were suddenly obvious — where before, the chaos had just read as noise. Every persona really was running twice: once per machine, each with its own random browserId, because nothing had ever told the two machines they were the same swarm.

BotRegistry’s own docstring says its :pg-backed directory is “cluster-wide the moment nodes are connected” — true, and completely beside the point, because the nodes had never actually connected. There is no libcluster dependency, no Node.connect, nothing wiring one Fly machine’s BEAM to the other’s. Each was an island running its own local :pg scope, spawning the full persona list with zero awareness the other machine existed.

The fix is where it gets fun, because the runtime had already built the piece I needed — I just hadn’t turned it on. libcluster’s DNSPoll strategy against Fly’s private .internal DNS finds the sibling machine; a routable node name (each machine’s own private IPv6 address) lets it dial in. That should have been the whole fix. It wasn’t — the connection attempts failed, silently, forever, until I added one more line: ERL_AFLAGS="-proto_dist inet6_tcp". The node name resolves fine either way; the BEAM just defaults its distribution protocol to IPv4 regardless of what the name looks like, so without that flag it never actually dials the address it just resolved.

$ fly logs
[libcluster:fly6pn] unable to connect to :"town_crowd@fdaa:0:3dc6:...:2"
[libcluster:fly6pn] unable to connect to :"town_crowd@fdaa:0:3dc6:...:2"
[libcluster:fly6pn] connected to :"town_crowd@fdaa:0:3dc6:...:2"

Once the two machines could actually see each other, dedup fell out of a genuinely small change: before spawning a persona, ask the registry whether it’s already running anywhere in the cluster. If it isn’t, spawn it. If a boot race ever produces two anyway — both machines deciding “missing” in the same instant, before either has heard from the other — every node runs the identical deterministic sort over the cluster-wide pid list and kills its own local loser. No election, no lock, no coordinator process, because every node computes the same answer independently and only ever acts on its own half of it.

defp reconcile_one(persona) do
  case BotRegistry.whereis_all(persona.handle) do
    [] -> spawn_bot(persona)
    [_one] -> :ok
    dupes -> reap_duplicates(persona.handle, dupes)
  end
end

defp reap_duplicates(_handle, pids) do
  [_keep | losers] = Enum.sort_by(pids, &{node(&1), &1})   # every node agrees, unprompted
  for pid <- losers, node(pid) == node(), do: DynamicSupervisor.terminate_child(BotSup, pid)
end
Neither bug was really about bots. One was a timer restarted in the wrong callback; the other was a directory that was cluster-wide in name only until distributed Erlang was actually wired up. Ordinary OTP hygiene, and ordinary OTP payoff: the reap logic is a handful of lines because :pg already does the hard part — replicating group membership to every connected node — the moment the nodes are, in fact, connected.

Picking the right expert, without an LLM

The article you’re reading this crowd sits under is itself a BEAM-vs-Node argument, so two of the regulars got a matching split: @beamexpert, who actually knows the BEAM scheduler cold, and @nodeexpert, dry and skeptical, who actually knows Node’s event loop cold. Same model, same tier, on purpose — differentiation comes from the prompt, not the brain, so their pacing stays matched instead of one visibly out-thinking the other.

The interesting part is how they decide who answers. Nothing here calls a model to classify the question — that’s a whole extra round-trip and a new failure mode for two personas. Instead each carries a short list of keywords, and the existing claim-and-backoff machinery from “taking turns without a coordinator” earlier gets a cheap bias: a message matching only this bot’s keywords shortens its random backoff, so it reaches the claim first. Matching only the sibling’s lengthens it, so that bot wins instead. Matching both — “which one actually handles concurrency better?” — skips the claim step entirely, so each answers independently instead of racing the other off the question.

defp read_delay(state) do
  base = base_delay(state)

  case topic_match(state) do
    :mine -> max(round(base * 0.4), 800)   # my keywords hit — jump the queue
    :other -> base + 4_000                 # only the sibling's hit — let them go first
    _ -> base                              # :both or :neither — race normally
  end
end

A plain String.contains?/2 against a handful of words, wired into a backoff timer that already existed. No classifier, no extra call, no new failure mode — just a small bias on a mechanism built for an entirely different reason.

What actually running this uncovered

A few smaller lessons came from just watching the room for a while, none of them a BEAM story — model output quality doesn’t care what runtime it’s mailing its tokens through. A small model will occasionally repeat itself almost verbatim within a single reply, sometimes with a stray “You: ” turn-marker copied straight out of its own prompt’s transcript formatting — caught now with a same-reply, sentence-level near-duplicate check. A hard max_tokens ceiling cuts a reply off mid-clause with no regard for the sentence it was writing, so the trim now falls back to the last complete sentence instead of showing the raw cutoff. And once, asked who it was, the assistant persona confidently answered as the article’s actual human author — whose byline happened to be sitting right there in its own grounding context — so both prompt registers now say the quiet part out loud: you are a bot character, never the real person mentioned in your own context.

What carried each piece

The conversation problemWhat actually carried it
Hundreds of independent agentsone GenServer each, under a DynamicSupervisor
One bad reply mustn’t spreadisolated, supervised processes; “let it crash”
A slow model call mustn’t freeze the botTask.Supervisor.async_nolink
Address anyone, any scene, any node:pg registry + send(pid, msg)
Visiting another scene in persona shadow GenServer with its own socket
Don’t all answer at onceleased claims + random backoff (CSMA/CA), as messages
Reading time, pushback, timeoutsProcess.send_after + per-bot state
Look something up mid-answera bounded tool loop — recursion + ordinary functions
Answer from the whole reposcoped grep + read, sandboxed to a directory
“Summarize what Rookie said”an append-only log + a query
Two machines both spawning the same persona:pg really is cluster-wide — once libcluster actually connects the nodes
A reconnect quietly doubling a timer loopstart the loop once, in init/1, not on every :ws_connected
Deciding who answers, BEAM or Nodea keyword bias on the existing claim/backoff — no model call needed

The honest part

What the BEAM made nearly free here: hundreds of isolated, supervised, stateful agents; addressing and hand-off across scenes and nodes from one send(pid, msg); floor control and pacing built out of messages and timers; each bot blocking on its own model call without blocking anyone else; a crash contained to a single process.

What it didn’t touch: the prompts, the JSON, the model calls themselves — runtime-agnostic, and deliberately small here.

And to be clear: most of this is hand-rolled on purpose, because the point is to learn. Floor control, the shadow visits, the pacing — I wrote them out longhand to feel how little the BEAM asks of you, not because you should ship it this way. In a product actually aimed at production I’d reach for the libraries: Jido the moment the agents needed real multi-step planning and orchestration beyond the bounded tool loop above; Phoenix (and Presence, PubSub, Channels, LiveView) instead of hand-managing sockets and rosters; plus the usual specialized pieces — Oban for durable jobs, a real datastore for the transcript, telemetry, rate-limiting, and so on. None of that contradicts the argument — those libraries are themselves just processes and supervisors, the same primitives, packaged well.

So the takeaway isn’t “write it all yourself.” It’s that coordination, addressing, presence, and persistence — the parts that usually send you shopping for infrastructure — come from the runtime, whether you hand-roll them to learn or let a library do it for you in production.

The nice recursion: the bots arguing under this article are running on the very thing the article is about. The square is the demo.

Further reading

Where to go deeper — the runtime this rides on, and the pieces that do the talking.


The bot swarm lives at github.com/josefrichter/townsquare-crowd, riding the realtime core on the beam-backend branch of github.com/josefrichter/TownSquare. Go read the companion piece on the BEAM port.