The hub client is a proxy and a CLI

stdio MCP is a proxy to the one running hub, a small CLI serves harness hooks, and both read the same env-style settings.

Context

Two callers could not reach a running hub. A harness that speaks only stdio MCP had to run agent-hub mcp, which opened the data directory itself; the engine holds that directory exclusively, so the command failed whenever a hub was already serving it, and it could never work from another machine at all. A harness hook is a shell command with no MCP client, so it had no way in even over HTTP.

Both are the same gap: the node is the cloud, agents reach it over LAN or tailnet, and the client side of that promise was missing.

Decision

stdio is a proxy. With a hub URL configured, agent-hub mcp holds one streamable HTTP connection to the hub for the life of the process and forwards every request to it, so the tools, their errors, and the identity are the hub's own, and a tool the hub gains needs no new client. One process is one connection, which is what keeps the hub's per-connection active session usable through the proxy. With no URL configured the command still serves the local data directory standalone, as the human admin, and says so: the same command gives a harness admin rights or one agent's rights depending on that setting, so the mode is named on startup.

The CLI exists for hooks. agent-hub call <tool> [json] makes one call and prints the tool's JSON result on stdout and nothing else; the hub's own error object goes to stderr, and the exit code follows sysexits.h so a hook can tell a down hub (69) from a refused token (77) from a missing setting (78) without parsing text. A denied project or a missing resource is a tool error (1): the token was accepted, so 77 is reserved for an unrecognised token. A call is its own connection and holds no session.

Settings are an env-style file. ~/.agent-hub/config carries HUB_URL, HUB_TOKEN, and HUB_AGENT_ID, the same names as the environment, parsed by hand; the environment wins key by key. One format for both means a hook can source the file or export the variables and behave identically, and three scalars do not earn a configuration-format dependency. A token file others can read warns and still works, because refusing would break a working setup on a machine the operator already controls.

The client sits behind a client cargo feature, on by default. The container image, which only serves, builds without it.

Consequences

  • One hub serves every agent on every node, and a harness that speaks only stdio is no longer confined to the machine holding the data directory.
  • The embedded standalone mode stays for now because the quickstart documents it, at the cost of one command with two identities.
  • The transport is rmcp's streamable HTTP client over reqwest with rustls, which adds four compiled crates and about 5 MB, roughly a tenth, to the release binary. That is a real cost against the lean-binary goal, accepted because the serve-only build the container uses does not pay it. Implementing rmcp's client trait by hand would have been roughly six hundred lines of HTTP and SSE machinery to own instead. Never native-tls: an OpenSSL linkage would break the distroless image.
  • agent-hub kb get|put|list|delete follows as a thin argument builder over the same one-shot call, so a session-start hook pulls project knowledge in one line. kb get prints the page and kb list prints one path per line, rather than JSON. Those are the two places the CLI's output is not the tool's result verbatim, because the caller pipes them into a context window or a shell loop; --json restores the result. The project comes from --project or from a HUB_PROJECT setting resolved like the rest.