Harness Hub keeps conversations connected

link-harness-hub serves a harness that speaks ACP over stdio to Link's bridges, as Link Harness serves its own: ACP over a WebSocket at the endpoint's /acp, opened with the agent's headers and its endpoint key. It's a package each adapter's relay is built on, in the adapter's own serve, never a process of its own.

The harness behind it

One harness runs behind the hub, started by the adapter's Start on the first message and again whenever its output ends. It answers /health once the harness has agreed on ACP, waiting a little for one still starting. A permission the harness asks for is given with the first option that allows, since Link's sandbox is what confines it.

A harness that can't start

A start that fails, or never agrees on ACP, is tried again only after a wait: 1 s after the first failure, doubling with each one in a row, at most a minute. Until then /health and every message are refused with the last failure's reason, so health checks can't restart a broken harness every second. A harness that answered once and then stopped starts again at the next message, with no wait.

Threads and sessions

A bridge names a session by its thread (telegram:42), as Link Harness does; the harness makes sessions of its own. The hub keeps which session is each thread's in a file, so a thread goes on where it was after a restart: taken up with the harness's session/resume, or session/load when that's all it has, else started anew. A thread's turns run one at a time, and each session's updates go back to the bridge under the thread's name.

What a prompt carries

A prompt is told taken (_link/taken) as it's forwarded, so a webhook's sender is answered then. When its _meta.link says it's from someone other than the owner, the hub writes who into its text, a line of its own with their name and hops, each of their lines quoted, so a harness that reads no _meta still knows; a sender that isn't one is refused before the harness sees it. The _meta goes on to the harness as it came.

A command by another name

The owner's command goes to the harness by a name it offers, as its session's latest available_commands_update lists them. A prompt that is one command, an action of Link's namespaces or one of its aliases (/chat:create, /new), goes as the first of the action's names the harness offers (/clear to Claude Code); one it offers, or one no action has, goes as it came. The names are the namespaces' plugins' actions.toml's.

The first command

A known command waits for its session's command list, including a list published after session/new answers. If no list arrives within the harness call timeout, the command fails before forwarding. Normal messages do not wait for command discovery.

Link's tools and memory go with each session the hub makes, resumes or loads, as its mcpServers: each an MCP server over HTTP at its address on Link's proxy. An adapter's configure keeps them (Tools::keep) and its serve hands them to the hub. A harness that says it takes no MCP server over HTTP (mcpCapabilities.http) is given none, and its adapter writes Link's tools into the harness's own settings instead.

Agent state

The hub answers the agent state of ASP for the harness behind it, each session named by its thread both ways. A harness that advertises agent state answers it itself. Otherwise the adapter's Store reads and writes the harness's own files, and the sessions a store doesn't give are replayed through the harness's own session/list and session/load, when it has them. Either is asked only between turns: an export or import waits up to two minutes for the turns running to end, and is refused if one still runs.

What comes in

An import's sessions each become a thread: under its old name when that's a thread's and the hub keeps none by it, else a new one. The hub keeps the session the harness made as that thread's, so the bridge's next prompt there goes on where the conversation was. A harness with neither answers that it takes none, kind by kind.

Its endpoint

The hub refuses a connection without the key, or naming an agent that isn't its own, and proves it holds the key before it's sent it, as the contract asks; Hub::serve sets TCP_NODELAY on each connection it takes.

Why it works this way

The hub's decisions explain its thread mapping, native state boundary and why it shares code inside each adapter's endpoint.

Kept tool delivery also holds the agent's proved action endpoints and names. The hub runs these owner commands through their routes before forwarding a prompt to the native harness, and returns the typed answer through ACP. It adds them to the harness's advertised command list. A message from another sender cannot invoke them. Per-harness conversation commands continue to use the native session's advertised names and aliases.