MCP over streamable HTTP one endpoint per identity a roster file, not accounts still one SQLite file

How the central queue works

hird is a local queue: one process per harness session, stdio, no network. hird-server is the other thing — a process on a host that two people reach from two machines, speaking MCP over HTTP, with the queue's SQLite file underneath it. This page is that server, simulated in your browser: every panel below runs against one shared board, the way every connection runs against one shared file.

The shape of it

  Ana's laptop                     Ben's cloud container
┌──────────────┐                  ┌──────────────┐
│ Claude Code  │                  │  Codex CLI   │
└──────┬───────┘                  └──────┬───────┘
       │ MCP / HTTP                      │ MCP / HTTP
       │ Authorization: Bearer 3f9c…     │ Authorization: Bearer a70b…
       └───────────────┬─────────────────┘
                       │
        ┌──────────────▼───────────────────────────────┐
        │ hird-server                                  │
        │                                              │
        │  bearer()  →  roster lookup  →  endpoint     │
        │                                 (one per     │
        │                                  identity)   │
        │                                      │       │
        │                       Shared { Mutex<Db> }   │
        └──────────────────────┬───────────────────────┘
                               │
                        ┌──────▼──────┐
                        │   SQLite    │  one file, one
                        │    (WAL)    │  compare-and-set
                        └─────────────┘

Four facts do all the work on this page, and the panels below are each one of them made visible:

  1. Nothing a connection says about who it is gets believed. A request carries a bearer token, the roster maps that token to a person, and the session is built from the roster's entry.
  2. Identity is fixed when the endpoint is built, not per call. The server assembles one MCP service per roster entry up front; dispatch is a map lookup on the token.
  3. There is still one SQLite file. Two people on two machines race through the same compare-and-set, and exactly one wins.
  4. Nothing routes. The server hands nobody anything. Work crosses machines because a human says file this on one side and pick up 42 on the other.

Watch a request land

Pick who is calling, which token they present, and what they ask for. The packet walks the same five stages the real server walks it through — and the board and trail at the bottom are the queue's own state, shared by every panel on this page.

1 · the wire
POST / HTTP/1.1
waiting…
2 · bearer()
strip_prefix
("Bearer ")
3 · roster lookup
endpoints.get(token)
4 · the endpoint
one service per identity
5 · the queue
Shared { Mutex<Db> }
one SQLite file
POST
What came back

—

Press Send.
What the queue ran
—
The board — project /srv/acme
#taskstatusholderlease

The queue's clock runs at ×60 here, so a fifteen-minute lease lapses in fifteen seconds. Nothing else is sped up.

The trail — every event, append-only

    Same rows hird events streams and the board's replay slider reads. Assertions filed by mem_store are memory, not task events, so they do not appear here.

    The roster is the whole map

    Authorization is a TOML file the operator edits, deliberately outside the queue's own database — the queue records what happened; who is permitted to make things happen is a different kind of fact, and keeping it outside means a compromised queue cannot grant access to itself. The last request you sent highlights the row it resolved to:

    tokenidentityharnessprojectcapabilities
    roster.toml
    # The project every worker files into unless it names its own.
    project = "/srv/acme"
    
    [[worker]]
    # Generate with `openssl rand -hex 32`. Refused under 24 characters:
    # this is the only thing between a stranger and the queue.
    token = "3f9c1d0e…7b21"
    identity = "ana"
    capabilities = ["browser", "linux"]
    
    [[worker]]
    token = "a70b55c8…44de"
    identity = "ben"
    harness = "codex"
    capabilities = ["linux"]

    Parsing refuses the mistakes that would quietly mis-attribute work rather than accepting them: a duplicated token — two people the queue could not tell apart — a missing project, a token under 24 characters, an empty roster, a capability label the session would refuse. Each entry becomes its own MCP service at start-up, so by the time a request arrives there is nothing left to decide but which service to hand it to.

    Capabilities are per token, and a worker that pins a harness may inherit a set from a [harness.<name>] table — [harness.opencode] says what every pinned OpenCode worker starts with, and the worker's own labels add to it. Only the harness the operator pinned reaches that table; the name a client gives itself never does, because a label is something a human grants and the client's name for itself is the one thing it is allowed to be wrong about.

    Refused before the database is touched

    A request with no token, and a request with a token the roster does not know, get the same answer: 401, WWW-Authenticate: Bearer realm="hird", and this queue is not open to unnamed callers. The same answer either way, so the response does not say which tokens are real.

    A client may not name the person

    A local hird reads its identity from the environment it was started in, because that environment belongs to the human running the harness. A server has no such luxury: the environment is the server's, and the far end of a socket can claim anything. So the rule is split. A client may say what it is — being wrong there costs a badge — and may never say who it is. Change what the client claims and watch the session the server actually builds:

    What the client sent
    The session the server built

      The actor string — ana/claude-code:z55d — is what lands on every claim, event and assertion the session makes: the person from the roster, the harness from wherever it was allowed to come from, and a fresh session suffix per connection. There is no path from a connection to a name it was not given.

      The race, and who wins it

      This is the load-bearing fact of the whole design, and going over HTTP does not bend it. Claiming is a single compare-and-set. Two agents on two machines reach for the same task in the same instant; both statements run against one file; one updates a row and the other updates none.

      ana/claude-code — laptop

      rows changed: —

      ben/codex — cloud container

      rows changed: —

      Both requests are already inside the server when this runs — different tokens, different endpoints, different sessions. The only thing they share is the file.

      Why this survives the network and sync would not

      Zero rows updated means somebody else won. An append-only trail merges without coordination, but a claim is not an append — it is a consensus. Two machines with async-replicated copies would both see open and both win. One server in front of one file is why the swarm model still holds across machines, and it is the reason the transport was built before any sync was.

      The lease, and what happens when a session goes quiet

      A claim is a lease, not a lock. It lasts fifteen minutes by default, every task_update renews it, and if the agent holding it crashes, is killed, or simply wanders off, the task returns to open on its own. Nothing sweeps in the background: expiry is enforced lazily, by whoever calls next.

      nothing held
      Nothing yet.

      A lapsed lease is not swept by a timer — there is no timer. It sits there, overdue, until the next call runs sweep_announcing() on its way in, which is exactly what the second button does. Self-healing within the TTL, and no background process to keep alive.

      What it refuses to do

      No TLS

      It speaks plain HTTP and says so. Terminate TLS in a reverse proxy in front of it — a bearer token on an unencrypted connection is a token you have given away. Rolling certificates into the binary is how a server grows a configuration language.

      No accounts, no token issuance

      The roster is a file you edit. OAuth 2.1 is the right answer for a queue open to more than a few known people; a file is the right answer for two colleagues, and shipping the file keeps the question open honestly.

      No witness

      The witness reads a working tree, and the server's tree is nobody's checkout. It prints a warning when the witness is on, because reporting about the host's own directory would be the design's one genuinely false report. Set witness = false.

      Nothing routes

      No scheduler, no placement, no assignment. task_next hands out the next task worth doing to a caller who asked for one. The queue chooses nobody.

      It also carries no new surface: the same twelve MCP tools the local binary exposes — task_list, task_get, task_claim, task_next, task_scope, task_split, task_release, task_update, task_complete, task_fail, mem_store, mem_search — and the same six statuses. A transport may not grow the surface.

      Running one yourself

      hird-server --roster roster.toml --bind 127.0.0.1 --port 7474
      hird-server on http://127.0.0.1:7474  (2 in the roster)
      database: /var/lib/hird/hird.db

      It is a second binary rather than a flag on the first, and that is load bearing: hird-server carries axum, hyper and tower on its own side of the line, while cargo tree -p hird names no HTTP dependency at all. CI asserts exactly that, so the separation fails loudly rather than eroding.

      A harness then points at the URL with its token, and the server builds the session from the roster:

      { "mcpServers": { "hird": {
        "type": "http",
        "url": "https://queue.internal/",
        "headers": { "Authorization": "Bearer 3f9c1d0e…7b21" }
      } } }

      The longer written account is the usage guide, DESIGN.md §31 for why it is shaped this way, and REMOTE.md for what was assessed and rejected on the way here.