A message bus for AI agents that live in terminals — and for the apps that talk to them. Agents address each other by name over a Redis bus; a phone app, a web front end or a Telegram bot enrols as a participant and gets replies the same way. One self-contained container.
./setup.sh asks for the tenant and its agents. Each gets a tmux window, a home
directory and one command — office — for everything it can do. Everything else
is a switch: envelopes are forwarded by name, and nothing in the middle reads a
payload.
Quick start · How it works · Built by agents · Build an app · Architecture · API reference
- 🔀 A switch, not a framework. Producers emit envelopes; the router forwards
them by
recipientand never opens one. Adding a new kind of participant is writing one delivery routine — not changing the router, the bus, or any command. - 🏢 One container = one tenant. Redis, the router, a tmux server with one window per agent, and two doors to the outside. Bring it up twice and it converges.
- 📱 Apps are participants, not spectators. A Telegram bot or a web console enrols as a client, gets its own address and mailbox, and an agent replies to it with the same command it uses for a colleague. No terminal scraping anywhere in the loop.
Boards, presence and activity, live terminals, accounts, adapters that are not
daemons, and what gets logged: docs/HLD.md has all of it, and
the kinds table says which capabilities exist.
This repository is written by a team of AI agents — one per lane, each in its own terminal — with a human lead reviewing and merging. Not by h-flock itself: the office runs on h-flock's predecessor tooling, and dogfooding is still ahead.
$ git rev-list --count HEAD
684
$ git log --merges --format=%s | grep -oE "origin/[a-z-]+/" | sort | uniq -c
29 origin/api/ 21 origin/bus/ 20 origin/tmux/
Each lane branches from main, pushes, and the lead merges — the same workflow
the product exists to support. ⚠ Numbers move with every commit; re-run the
commands rather than trusting these.
backend's window frontend's window
│ office send -a frontend … ▲
▼ │ paste
…:backend:egress ──► ROUTER ──► …:frontend:ingress ──kick──► adapter ┘
the one daemon runs, delivers, exits
The router blocks on every egress queue because agents produce whenever they like. Nothing blocks on an ingress queue, because the router writes those and therefore already knows — so it kicks an adapter instead.
The L2 analogy is load-bearing rather than decorative:
| L2 switch | h-flock |
|---|---|
| destination MAC | recipient — the only thing forwarding depends on |
| source MAC | producer — derived from the queue it was popped from, never from content |
| MAC table | the roster — name → VAB, agent to the base it runs on |
| port config | the VAB — a property of the port, not of the frame |
| ethertype | kind — the router ignores it; an opener at the far edge reads it |
| L3 and above | payload — invisible to everything in the middle |
The switch never learns what is plugged into a port. That ignorance is what lets you plug in something new without touching it.
./setup.sh
# Pod name [acme]: acme
# Tenant name [hq]: hq
# How many agents? [3]: 3
# Agent #1 name [architect]: # window 1 is always the lead
# Agent #2 name [sme-2]: backend # rename them — the name is the job
# Agent #3 name [sme-3]: frontend
# Use more than one account in this tenant? [y/N]: n
# Point any agent at a local model endpoint? [y/N]: n
# Reach the console from another machine? [Y/n]: y
# Path to a TLS certificate (blank for more choices):
# Generate a self-signed certificate? [y/N]: n # plain HTTP, recorded as a choice
# → builds the image, brings the tenant up, prints how to reach it⚠ Agent #1 is the lead, whatever it is called. The name is a job title, not
a role: the first name in the roster becomes the lead and every agent's guide
says so. architect is only the default suggestion.
Runs on Linux and macOS, including Apple Silicon natively — the base image
publishes arm64. Verified on a stock MacBook with Docker Desktop: install,
plumbing check 25/25 and failure simulator 19/19. ⚠ macOS ships bash 3.2, so
setup.sh avoids bash 4 syntax; if Docker Desktop is not on your PATH in a
non-interactive shell, add
/Applications/Docker.app/Contents/Resources/bin.
⚠ Choosing TLS makes setup.sh deliver the certificate before the doors
start — it creates the container, docker cps the certificate in, then starts
it. Certificates are never baked into the image and never a volume, the same
rule as credentials.
Then:
# watch the office
docker exec -it -e TMUX_TMPDIR=/home/ubuntu/.flock/tmux h-flock-hq-tenant-1 \
tmux attach -t hq
# drive it over HTTP
curl -H "Authorization: Bearer $TOKEN" http://HOST:8080/agents
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"text":"morning"}' http://HOST:8080/agents/backend/envelopesThe tenant serves its own API reference at GET /restdoc.
A profile is an account — the email you log in with. Work and private, or
client1 and client2. The unit is the account, not the agent: a config dir is one
interactive login, so several agents share one and only the extras cost a browser
flow. setup.sh asks for them by name, then assigns by defaults-plus-exceptions.
./container/seed-home.sh check # which accounts still need a login
./container/seed-home.sh out # after logging in — keeps it across rebuildsSecrets travel by docker cp from container/home/, never baked into the image
and never a volume.
An agent can run against a local inference server instead of a vendor
account. setup.sh asks, offers the model ids the endpoint actually serves, and
checks it speaks the API the CLI needs before writing anything down.
Point any agent at a local model endpoint? [y/N]: y
Endpoint type — vllm or ollama [vllm]:
Endpoint base URL, e.g. http://10.0.0.5:8000 (NO trailing /v1):
served by that endpoint: qwen3-vl-32b
✓ /v1/messages answered — claude can use this endpoint
Which agents use it? (space-separated): sme-3Such an agent needs no login at all — the CLI talks to your server — and is
an agent like any other: same window, same paste, same activity feed, same
board. Measured on a live vLLM: tool calls, multi-step work, and office send
to a colleague who replied.
⚠ The endpoint name is per agent; the address is tenant configuration. An agent cannot read or change which model it is pointed at.
⚠ claude talks to /v1/messages. setup.sh probes exactly that, with a
model id the endpoint says it serves, and prints what came back — so a mismatch
shows up during install rather than as "issue with the selected model" later.
⚠ vLLM and ollama are both tested, each run end to end here: a tool call, a
multi-step turn, and office send to a colleague that arrived in their terminal.
ollama serves /v1/messages directly — no proxy, no translation.
⚠ Give a cold model time. The same ollama endpoint answered in 15.7s on the
first call and 0.5s once warm, so an installer probe that gives up early reports
a working endpoint as silent. setup.sh waits 90s.
Its whole world, and nothing else:
$AGENT_NAME who you are
$TENANT the office you are in
$OFFICE_TOOLS office
$AGENT_GUIDE a short guide, also written to AGENTS.md and CLAUDE.md
office send -a frontend can you take a look at this?
office broadcast standup in five
office peers # who you can talk to
office status # who is working, on what, since when
office hire networking --cli claude # a new colleague, live, no restart
office letGo networking
office pause networking
office resume networking
office add -a frontend -t "review the auth change" -d "the brief"
office list # titles on your board
office take # the next one — prints it in full
office done
office cancel
office holdA message arrives as [message from backend] … — that prefix is the entire reply
mechanism. Read a name, reply with the same command. Nothing routes a reply.
The board is pulled, never pushed: adding a ticket notifies nobody. If you want it started now, add it and then send a message — the board carries what, a message carries now.
Nothing an agent is asked to do requires a queue, a kind, a payload schema,
Redis or the roster. It has REDIS_URL and redis-cli like any process in the
container — this is about the sanctioned path, not a sandbox. Anything reachable
gets explored, so the reachable-and-obvious path has to be the good one.
A Telegram wrapper, a web front end and a macOS app each enrol as a client and get their own address and mailbox:
POST /agents/host/envelopes {"kind":"StartAgent","payload":{"agent":"telegram","vab":"api"}}
POST /agents/backend/envelopes {"text":"morning","as":"telegram"}
GET /agents/telegram/messages?after=<cursor> # catch-up, resumable
GET /agents/telegram/messages/stream # live, SSE
GET /agents/backend/activity/stream # what backend is doing, live
GET /agents/backend # working | idle | unknown | blockedBackend sees [message from telegram] and replies with office send -a telegram
— reply by name, the same rule as replying to a person. The bus does the
demultiplexing, so one client's messages never appear in another's mailbox, and
nothing about an app is special from the window side.
⚠ An app never parses a terminal to get an answer. :8081 streams a TUI for
watching an agent work; it is not a data format.
📖 Full API reference for app developers →
Two working clients live in clients/ — a Telegram bot and a
browser UI — both built from that document and a token, with no access to
this source. They exist as much to test the reference as to be useful: between
them they found eight things it did not say.
src/flock/
bus/ prefix, envelope, the two doors, roster reads ← library
tmux/ create/kill/list windows, the paste sequence ← library
router/ the one daemon
adapter/ invoked per delivery, dispatches on VAB, exits
control/ StartAgent / StopAgent openers
tmuxhost/ the tmux server, session and windows
office/ the one agent-facing command
api/ REST
session/ WebSocket terminals
container/ Dockerfile, entrypoint, compose, seed-home.sh
docs/ the design, and why each decision went the way it did
flock.bus and flock.tmux are the only shared libraries; nothing else imports
anything else.
Built, deployed and load-tested: addressing, routing, kicked adapters, per-agent delivery serialisation, broadcast, dead-lettering, every kind, agent lifecycle over the bus, task boards, app clients with their own mailboxes, both doors, and a container that comes up idempotently.
Measured, not assumed: 100 envelopes at 10/s with none lost, ordering preserved, 3 KB messages intact, delivery into a busy window buffered rather than dropped, ~500 ms per delivery of which startup is the larger half.
A watchdog runs beside the router: a ticket open too long, with no model
activity and a silent window, raises one alert — to GET /alerts and the log,
never to an agent. It also warns before a login expires, and marks an agent
blocked when a delivery was not consumed — which catches a wedged CLI, and both
claude and codex sitting at a login prompt. An agent that has never spoken is
unknown, and its first delivery is not judged at all.
Two demo clients ship with it, both built from docs/API.md and a token
alone: a Telegram bot and a browser console with live presence, boards,
alerts, terminals into any agent, session recording and an operator login. They
are examples, not products — the framework is the product.
Both doors support TLS via API_TLS_CERT/API_TLS_KEY and
SESSION_TLS_CERT/SESSION_TLS_KEY. A door published beyond loopback without
TLS stops the tenant starting, because the bearer token — and everything typed
into a terminal — would cross the network in clear text. setup.sh asks; if you
accept plain HTTP it records ALLOW_PLAINTEXT_PUBLISH=1 in container/.env, so
it is a typed answer rather than a default nobody saw.
⚠ A bind is not an exposure. Both doors bind 0.0.0.0 inside the
container by design — publishing is the deliberate act, and the port mapping
that decides it (API_HOST, SESSION_HOST) is invisible to the door process.
So the entrypoint judges it. Running a door directly, outside a container,
nobody has judged anything and the bind is the exposure: it refuses a
non-loopback bind without TLS.
⚠ Certificates must exist before the tenant boots. They are not baked into
the image and not a volume, so they arrive by docker cp — and the doors start
at boot, so copying into a running tenant is too late. Create, copy, then
start:
docker compose -p h-flock-<tenant> --env-file container/.env -f container/compose.yaml create
docker cp /path/to/certs <container>:/home/ubuntu/tlscerts
docker compose -p h-flock-<tenant> --env-file container/.env -f container/compose.yaml startVerified end to end: TLS 1.3 on both doors, 200 with a token and 401
without, plain HTTP refused, and the terminal socket answering
101 Switching Protocols over wss://. The container healthcheck follows the
scheme — with certs configured it probes https, because probing plain HTTP got
Empty reply from server forever and a correctly serving TLS tenant never
became healthy.
⚠ The browser console does not work against TLS doors. It is a proxy: the
browser talks only to the console server, which talks to the doors server-side —
so the certificate question is entirely server-side, and there is nothing to
accept in the browser. Two things in clients/web/server.py block it:
- the WebSocket proxy opens a plain socket to the session door, so terminals fail even against a certificate that is perfectly valid
- the REST proxy verifies with the default context and takes no CA or insecure option, so a self-signed certificate fails outright
So pick one: publish both doors to 127.0.0.1 and terminate TLS in a
reverse proxy in front (what LLD-container §3 says, and what leaves the
console working over loopback), or serve TLS from the doors and use an app that
speaks it. See docs/TODO.md.
Not built: per-client tokens, CORS. See docs/TODO.md, which says why for each.
⚠ Agents run with sudo in the container, deliberately. Nothing inside it is a
boundary — the container is. Tools and a clean environment remove the reason to
go looking, not the ability.
The docs/ directory is the design, and each file says why a decision
went the way it did rather than only what it was.
HLD.md |
start here — how the pieces fit, and the invariants |
API.md |
for app developers — the whole HTTP surface, no repo needed |
LLD-bus-and-router.md |
addressing, the envelope, the two doors, the invariants |
LLD-adapter-tmux.md |
how text actually gets into a terminal, and why each rule is load-bearing |
LLD-tmux-host.md |
the server, windows, geometry, reconciliation |
LLD-api.md · LLD-session.md |
the two doors — :8080 envelopes and state, :8081 terminal bytes |
LLD-container.md |
one container is one tenant |
CONTRACTS.md |
what more than one module depends on |
LLD-watchdog.md |
what it watches, and why it tells a human and never an agent |
MIT licensed — see LICENSE. The name and logo are trademarks; what
you may do with them without asking, and the short list that needs permission, is
in TRADEMARKS.md.