BrAIn, reactive AI agent nodes on a NATS bus instead of a chat loop
brAIn is an experimental AI agent framework that replaces the traditional chat loop with a NATS pub/sub bus architecture of long-lived daemon nodes. Nodes are reactive, only activating when relevant messages arrive, saving token consumption. Each node can have its own UI, supports distributed deployment, and features priority preemption and MCP client integration. The author demonstrates applications like ambient room agents, Slack listeners, and IoT controllers, and compares the architecture with existing tools such as LangGraph, AutoGen, and ROS 2.
Notifications You must be signed in to change notification settings
Fork 0
Star 1
BranchesTags
Open more actions menu
Folders and files
NameName
Last commit message
Last commit date
Latest commit
History
282 Commits
282 Commits
.github
.github
.idea
.idea
packages
packages
scripts
scripts
tests
tests
.dockerignore
.dockerignore
.env.example
.env.example
.gitignore
.gitignore
.npmrc
.npmrc
AGENTS.md
AGENTS.md
ARCHITECTURE.md
ARCHITECTURE.md
CHANGELOG.md
CHANGELOG.md
Dockerfile
Dockerfile
LICENSE
LICENSE
README.md
README.md
TODO.md
TODO.md
docker-compose.yml
docker-compose.yml
eslint.config.mjs
eslint.config.mjs
package.json
package.json
pnpm-lock.yaml
pnpm-lock.yaml
pnpm-workspace.yaml
pnpm-workspace.yaml
run
run
run.cmd
run.cmd
sonar-project.properties
sonar-project.properties
tsconfig.base.json
tsconfig.base.json
vitest.config.ts
vitest.config.ts
Repository files navigation
I'm a Flutter mobile and AI engineer, and honestly I've never been satisfied with the agent frameworks out there. Most of them boil down to a loop or a cron poking a model on a timer, with everything funnelled through a single chat. So I tried to build the thing I actually wanted to use. Full transparency: I leaned on AI heavily to write this code (that's part of the experiment too), and I think what came out is worth sharing.
Under the hood it's really a NATS pub/sub bus with long-lived daemon nodes, closer in spirit to ROS than to a chat framework. The LLM is just one thing a node can reach for, not the center of gravity.
A few things I think it gets right:
It doesn't burn tokens waiting. Nodes are reactive, not scheduled. They stay parked and only call the LLM when a message they actually care about arrives. No cron, no idle polling, so you pay for thinking instead of ticking.
Every node can have its own interface, local or remote. A node isn't just a handler; it can ship a UI, and that UI stays reusable and reachable even when the node runs on another machine.
You watch your agents, not a chat box. Instead of one conversation doing everything, you get a live graph of small, dedicated nodes, each with one job and its own view.
You can spread the load. The same node runs in-process or on a remote machine joined to the shared bus. Distribute work across hardware without touching the code.
It's not a polished product and it's not trying to replace anything. It's my honest attempt at a problem I don't think is solved yet. If any of this resonates, take a look.
🎥 Watch the explainer
brain-explainer-web.mp4
A 2-minute tour of what makes brAIn different — reactive many-to-many nodes, criticality with preemption, the live dashboard, any-model-per-node, distributed + MCP, and what you can build with it. ▶︎ Also on YouTube.
See it in action
Talk to it
voice.chat.with.intent.2026-07-08.at.16.02.16.mp4
Sound on. A video is replayed as a virtual camera and microphone, and the whole perception stack treats it as live input. voice transcribes and diarizes the two speakers, gaze tracks who is looking where, and intent pairs the two: people talking to each other stays context, but the moment a speaker addresses the camera the brain wakes up, answers, and the Kokoro tts node speaks the reply out loud. No wake word, and overheard chatter never costs an LLM call.
A live graph of your whole network
The dashboard is the network: every node, the messages flowing between them, and the wiring you edit by dragging. No single chat, just each agent doing its own job.
Distributed across machines
Two machines share one bus and one canvas. Nodes spawned on a peer show up next to the local ones, so you can spread the load across hardware. The wiring doesn't care where a node actually lives.
Each node can ship its own UI
A node is more than a function: it can serve its own interface. Here a small "brainpet" node with a dedicated UI, reusable and reached the same way whether it runs locally or on a remote peer.
Reach your nodes from anywhere
A tic-tac-toe node played through the Telegram bridge. The same node logic is exposed over an external channel, so interfaces stay reusable across the bus, even off your machine.
Your phone is a node too
phone.loneliness.mp4
brAIn-mobile turns a phone into a node: its sensors (light, motion, battery) stream onto the bus like any other publisher, and the brain reacts to what physically happens to the device. Here it has strong opinions about where it just got put down. The reply loops back to the phone's own screen and speaker, so perception and reaction both live at the edge.
How brAIn relates to other tools
brAIn isn't a competitor to most of the agentic tools you may know; they solve adjacent problems and the right one depends on the shape of the agent you want.
LangGraph / Vercel AI SDK / Mastra: for chat-shaped agents (user types → LLM thinks → tools → LLM responds), these are excellent and deeper than brAIn's chat support. Reach for them when the agent is fundamentally a conversational interface.
AutoGen / CrewAI: multi-agent conversations with roles. Use these when you want several LLM personas debating or collaborating within a single dialogue.
ROS 2: the closest architectural cousin. Pub/sub bus, daemon nodes, multi-language, cross-machine over DDS. brAIn shares a lot of its mental model with ROS. The differences are domain-specific: brAIn is built around LLM constraints (token budgets, abortable inference, tool-call loops, MCP), runs over NATS rather than DDS (easier to deploy when you don't need real-time guarantees), and preempts at the work-in-progress level rather than between callbacks.
Inngest / Trigger.dev / Temporal: durable workflows. If your agent is a finite-shape DAG with retries and backoff, those are production-grade options. brAIn's nodes are open-ended daemons; the two run on different mental models.
n8n / Flowise / Langflow / Dify / Node-RED: visual node-based authoring. brAIn nodes are written in code (a TypeScript handler plus a config.json, or any HTTP/WS service via transport: "web"). The dashboard is observation-first.
Claude Cowork / OpenAI scheduled tasks / cron-driven agents: time-triggered prompts. brAIn is event-triggered; if your agent's cadence is "every Monday morning summarise X", a scheduler is the right primitive.
A condensed comparison of the architectural traits brAIn happens to have, for context:
brAIn LangGraph AutoGen ROS 2
Long-lived daemon nodes yes graph runs per call per conversation yes
Many-to-many bus yes inputs flow through the graph conversation channel yes
Mid-handler abort on priority yes (LLM/CLI/MCP signal-aware) no no priorities at the queue level
LLM-native primitives yes yes yes no
Cross-machine NATS no no DDS
MCP client yes (4 transports) per-tool wrappers per-tool wrappers no
Causal trace + replay yes (/network/traces/:id/replay) LangSmith captures traces no no
A runtime for autonomous agents that live in a many-to-many event world.
Nodes are long-lived daemons. Each one subscribes to several input streams (chat messages, sensor events, webhooks, internal bus traffic, anything you can publish), reacts when something relevant shows up, and can publish to as many outputs in parallel. There's no single triggering channel: the agent watches and decides when to act.
A node may also be preempted mid-flight: when a higher-criticality message lands during a slow operation (an LLM call, a tool invocation, a CLI agent), the runner aborts what's in progress and re-runs the handler with the new context surfaced in ctx.preemptionContext. Same node config can run in-process, behind a WebSocket, or on a remote brain-agent joined to a shared NATS bus.
Concretely
A few shapes you can build with this:
An ambient room agent that watches camera + mic and only speaks when someone is looking at it while talking (the flagship demo: voice + gaze + intent + brain).
A Slack-channel listener that lives in a thread, picks up context across messages, and summarises or replies when the conversation pauses.
A monitoring agent subscribed to Grafana alerts + oncall-rotation events + recent deploys, that surfaces a hypothesis when a correlation crosses a threshold.
An IoT controller that fuses temperature, motion, calendar, and time-of-day to decide when to change the environment.
The framework's primitives:
Daemon model: nodes live across iterations; the framework auto-parks them when idle and the bus wakes them on the next subscribed message. State persists across runs.
Many-to-many I/O: each node subscribes to N topic patterns (with wildcards) and publishes to as many.
Criticality with preemption: every message carries a criticality. A higher-criticality message arriving mid-handler aborts the running iteration and triggers a re-run with ctx.preemptionContext.{interrupting_message, previous_messages} available.
Distributed runtime: same node config runs in-process or on a remote brain-agent. Lifecycle (stop / start) and read-back (logs / mailbox / DLQ) work transparently across machines over NATS.
MCP-native: mcp-config (manager) + mcp-server (one per upstream) bridge any MCP server's tools onto the bus, so the agent reaches into filesystem, git, Slack, Linear, Notion, Sentry … as it would call any other node.
Engine
Bus + mailboxes
packages/core/src/bus: NatsBusService implements IBusService on top of a NATS broker. The framework boots an embedded nats-server (the bundled Go binary, downloaded by the postinstall hook) on a free localhost port; remote brain-agent processes connect to that same broker and share the bus. Set BRAIN_NATS_URL to skip the embedded broker and join an external one instead, typical when running across multiple hosts.
BusService (in-memory) is still exported but only as a test fixture; the production code path always goes through NATS.
Known limitation (scaling): each instance subscribes to the whole hierarchy (.>) and filters locally with matchTopic to keep brAIn's wildcard semantics. Every instance therefore sees all bus traffic and filters it in memory, fine at small/LAN scale, but the first bottleneck as instance/message counts grow. The targeted fix is narrower NATS subscriptions per real subject.
Features:
Wildcard topic matching (alerts.*).
Per-subscription mailbox with configurable max_size and a latest / lowest_priority retention policy. dropped and capacity are exposed so the dashboard can show per-mailbox backpressure.
Causal traces: every message carries trace_id + parent_id. Survives NATS encode/decode, queryable via GET /network/traces/:id. Each trace can be replayed as fresh emissions through POST /network/traces/:id/replay: fresh ids, rewritten parent chain, original trace_id carried as metadata.replayed_from for debugging.
History sliding window (10k messages by default).
Runners
packages/core/src/runner, picked from a node's tags (not its name): a node carrying the llm tag gets the LLMRunner, anything else gets the ServiceRunner (a web transport overrides both). The node names in parentheses below are just examples of nodes that carry each tag.
ServiceRunner for reactive non-LLM nodes (memory, http-bridge, terminal, …): message arrives → handler called once → node parks until the next subscribed message arrives.
LLMRunner for LLM nodes (brain, memory-proxy, memory-consolidator): handler called in a budget loop (default 5 iterations). New messages reset the budget (fresh attention). When exhausted → node parks until something rewakes it.
Preemption
When a higher-criticality message lands while a handler is running, the runner aborts the iteration instead of waiting it out.
ctx.signal is an AbortSignal exposed to every handler. LLM handlers pass it to generateText({ abortSignal: ctx.signal })
[truncated for AI cost control]