Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

738 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

h-flock — a message bus for agents that live in terminals

One tenant per container Bus Apps License

Python Docker tmux FastAPI Agents Tests

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


✨ What it is

  • 🔀 A switch, not a framework. Producers emit envelopes; the router forwards them by recipient and 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.

🏗️ Built by an office of agents

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.

⚙️ How it works

  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 rostername → 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.

🚀 Quick start

./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/envelopes

The tenant serves its own API reference at GET /restdoc.

Accounts

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 rebuilds

Secrets travel by docker cp from container/home/, never baked into the image and never a volume.

Your own model

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-3

Such 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.

🧑‍💻 What an agent sees

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 hold

A 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.

📱 Build an app

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 | blocked

Backend sees [message from telegram] and replies with office send -a telegramreply 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.

📁 Layout

  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.

📊 Status

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 start

Verified 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.

📚 Docs

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages