0075 — External interfaces: a first-class protoagent CLI + tightened MCP / API / model onboarding
- Status: Accepted
- Date: 2026-07-05
- Implementation: D1 CLI (#1816), D3 MCP profiles + HITL-hang fix (#1817), D5
protoagent model(#1821), D4 goal-API dedupe (#1822) · real/v1usage (#1825), and D2 the sharedops/layer —GET /api/mcp/exposed+ resolver extraction (#1824), theops/package +knowledge.ingest(#1826),plugins.install_and_activate(#1827),config+fleetops (#1828), theGET /api/operationscatalog +protoagent operations/config get·setCLI (#1830), andprotoagent knowledge ingest(#1831) — are shipped. Remaining: thesafe-operatorMCP profile (needs the admin ops exposed as MCP tools + the ADR 0071 consent gate) and the D5 HuggingFace local-app registration (external PR). - Builds on: ADR 0033 (ACP runtime + operator MCP server), 0027 (plugin CLI + installer), 0041 (workspace CLI + tiered stores), 0042 (fleet CLI + supervisor), 0047 (settings cascade +
config explain), 0019 (managed / consumed MCP), 0066 (federation token + the/apioperator ceiling), 0071 (plugin trust-and-consent). - Related: protoCLI
proto— the A2A client + ACP server (ADR 0024/0025); #1504.
Context
protoAgent has three external interfaces — a CLI, an operator MCP server, and an HTTP API (/api + OpenAI-compat /v1 + A2A /a2a). Each grew on its own, and an audit (2026-07-05) found they are two disjoint shared spines, not one operation model:
- Agent-tool spine — the native tool-loop and the operator MCP both re-project
tools/lg_tools.py::get_all_tools(). One source of truth; the MCP is a genuine re-projection, not a reimplementation. ✅ - Instance-admin spine — the CLI and the REST API both call the
graph/**cores (installer.install,fleet.supervisor,config_explain, …). Shared per pair. ✅ - The two spines barely intersect. Admin operations (plugin install, fleet up, config edit, MCP-server management) are not agent tools, so they can never appear on the MCP; most agent tools have no REST twin. Only
knowledge_ingestspans both — and it duplicates its extract/transcribe/add glue in the tool and the route.
Three concrete symptoms:
python -m server …is the only admin front door, and it's undiscoverable. Theplugin/workspace/fleet/skills/configsubcommands are hiddenif sys.argv[1] == "plugin"branches inserver/__init__.py::_main()— they don't even appear in--help. There is no lifecycle verb (up/down/status), nosetup, no installable command name. It reads as an internal module invocation, not a product.- "Operate the instance over MCP" is only half-true.
server/operator_mcp.pyexposes the conversational toolset (memory / notes / tasks / goals / skills /delegate_to) but not administration — you cannot install a plugin, drive the fleet, or edit config over MCP. (It also has a real bug:ask_human/request_user_inputare HITLinterrupttools included in the"*"set; called over a foreign stdio MCP they hang the turn.) - The REST surface has drift —
goalsvsgoal, and one memory store split across/api/memory+/api/knowledge+/api/memory/injections(three modules, two prefixes); plus REST-only orchestration glue (install-and-activate, ingest) hand-written on top of the shared cores, so a CLI install can't hot-reload a live server.
The one part that is already clean: authentication. a2a_impl/auth.py::A2AAuthMiddleware is a single default-deny gate covering /api, /v1, and /a2a identically (Bearer / X-API-Key / federation-token / SSE query-token; open mode when unset). This ADR reuses it unchanged.
Separately, onboarding a local LLM (Ollama, HuggingFace TGI, LM Studio, vLLM) to power protoAgent is a pure config move today — graph/llm.py::create_llm builds a plain ChatOpenAI(base_url=config.api_base, model=config.model_name), so the LiteLLM gateway is the default, not a lock-in — but there is no discovery, no model command, and no "use in app" handoff, so it isn't fast.
proto (protoCLI) already owns two legs of the agent relationship — a faithful A2A client (packages/cli/src/a2a-client/, mirroring evals/client.py + the :7870-7879 port-scan) and an ACP server (proto --acp, so protoAgent's delegate_to acp spawns it). It has zero runtime lifecycle — it assumes a protoAgent is already running. So the missing leg is unambiguously protoAgent's own to own.
Amendment (2026-09, ADR 0111): "the ACP-server role is
proto --acp" is true for proto's agent only. protoAgent's own agent is exposed to ACP clients (Zed's Agent Panel, JetBrains, Neovim…) by the standaloneprotoagent-acpshim (integrations/zed-acp/), a stdio ACP server that is a pure A2A client of a running instance. The two coexist:proto --acp= proto's brain reaching protoAgent as tools;protoagent-acp= protoAgent's brain in the editor.
Decision
Treat the CLI, MCP, and API as one operation model with three faithful projections, and give protoAgent a first-class terminal identity. Five decisions:
- D1 — A first-class
protoagentCLI: the admin + lifecycle control plane. Retirepython -m server <sub>as the user-facing entrypoint in favor of an installableprotoagentcommand with a real subcommand tree and--help. Keepprotoas the interactive A2A chat client; the two meet at the wire (A2A / ACP), never in one binary. Locked: the command is namedprotoagent. - D2 — A shared
ops/layer. Promote the currently REST-only orchestration (install-and-activate, ingest, fleet up, config set) into one function per operation that carries the full orchestration, consumed by all three surfaces. "One operation, three projections" — the CLI verb, the MCP tool, and the REST endpoint call the same op. - D3 — Extend the operator MCP to the FULL admin surface, via curated profiles + consent. The admin ops from D2 become MCP-callable, so a foreign brain (Claude Desktop, Cursor) can
plugin install,fleet up, assign work, etc. Gated by (a) curated profiles (read-only/safe-operator/full) instead of all-or-name-by-name, (b) ADR 0071 trust-and-consent for the admin tier, and (c) the ADR 0066/api-ceiling posture (admin ops are the very set that ceiling protects). Fix theask_human/request_user_inputhang (exclude from"*", likeexecute_code). Locked: full admin over MCP. - D4 — Tighten the HTTP API now, breaking changes accepted. Dedupe the split namespaces (
goal[s],memory/knowledge/injections), fill in real/v1usage(stubbed to zeros today), and publish a machine-readable operation catalog the three surfaces derive from. Ship one release of deprecation aliases, then delete. Locked: do it now, complete it. - D5 — Fast local-LLM onboarding + "use in app".
protoagent setupdiscovers running local providers; aprotoagent modelverb group manages the model; and protoAgent registers as a HuggingFace "Use this model" local-app so a HF model card hands a model straight to it. Revised after research (2026-07-05): for an agent runtime the "use in app" contract is a CLI snippet via a PR tohuggingface.jslocal-apps.ts, NOT aprotoagent://deep-link — protoAgent maps onto the existinghermes-agent/openclaw/pipattern (which are the Hermes/OpenClaw competitors, already registered on the same cards). A deep-link is only for a GUI loader (optional, secondary). Locked: ship bothuv tool/pipxand a zero-Python frozen-binarycurl | shinstall path.
Design details
A. The protoagent CLI
A console_scripts entry (protoagent = "server.cli:main") over a real argparse/typer subparser tree. The existing dispatchers are re-parented, not rewritten:
protoagent setup # interactive provider/model/key wizard (new; mirrors proto setup)
protoagent up | down | status # lifecycle: start/stop/inspect the default instance (new)
protoagent serve [--port] # run in the foreground (= today's `python -m server`)
protoagent plugin … # ← graph/plugins/cli.py (exists — re-parent)
protoagent workspace … # ← graph/workspaces/cli.py (exists — re-parent)
protoagent fleet … # ← graph/fleet/cli.py (exists — re-parent)
protoagent skills … # ← graph/skills/cli.py (exists — re-parent)
protoagent config … # ← graph/config_explain.py (exists — re-parent)
protoagent knowledge ingest|search # over the store / the D2 ingest op (new, thin)
protoagent model … # local-LLM onboarding (see E) (new)
protoagent mcp serve|profiles # run/inspect the operator MCP (see C) (new, thin)up/down/status reuse the fleet supervisor's process model (detached spawn + boot-watch + liveness) applied to the default instance — no new supervision primitive. Chat is deliberately absent: it's proto's job. python -m server keeps working as the internal module form (the frozen sidecar re-invokes it); protoagent serve is the friendly alias.
Amendment (2026-09, fleet deck — #3466, ADR 0042): "chat is absent, it's
proto's job" is superseded for the fleet deck. Bareprotoagent fleet(orprotoagent top) opens the deck — the operator's terminal for the fleet: the roster, every hub on the box (--all), the work feed, member management (create / rename / delete / remotes / order, through theops/layer, D2), and talking to members — a conversation with tool cards, steering a running turn, answering the question / form / approval a turn parked on, cancelling one delegation. That last part is chat, and it belongs here because it is an operator act on a fleet member (attend a session so a scheduled turn parks instead of auto-answering; interject into a server-fired turn), not a coding-agent session. The division of labour is now:protoremains the coding-agent client (A2A client + ACP server, ADR 0024/0025) that is driven by an agent; the deck is the terminal that runs the fleet, members included. The two still meet at the wire — the deck speaks the same A2A //apithe console does (SubscribeToTask, the member bus, the HITL callbacks) and adds no surface of its own. Textual is imported only when the deck opens, so--helpand every non-interactive verb stay Textual-free; the desktop sidecar bundles it (--collect-all textual+ thedeckpackage), so the frozen binary opens the deck too. Open question 2 below is resolved by the same slice.
B. The shared ops/ layer
A new ops/ package: one module per domain, each a function that takes plain args + a context and returns a plain result, wrapping the graph/** core plus the orchestration that is REST-only today. e.g. ops.plugins.install_and_activate(url, ref, *, activate: bool) calls installer.install and the auto-enable + hot-reload dance (plugin_routes.py L202-260) that a CLI install currently can't do. The three surfaces become thin adapters:
- CLI adapter → parse argv → call the op → render text/JSON.
- REST route → validate body → call the op → JSON.
- MCP tool → the op wrapped by
to_fastmcp, admitted by profile (C).
knowledge_ingest (the one cross-spine op) collapses to a single ops.knowledge.ingest shared by the agent tool and the route, ending the double-glue.
C. Operator MCP: admin profiles + consent
Extend operator_mcp.operator_tools() to admit the D2 admin ops, selected by a named profile rather than a raw allowlist:
read-only— status / list / explain / search (no mutation).safe-operator— read-only + curation + non-destructive ops; no install / fleet control / config-rewrite.full— everything, incl.plugin install,fleet up/down,config set, work assignment. Requires an explicit consent ack (ADR 0071) recorded per client, and honors the ADR 0066/apiceiling (a federation-token peer never reachesfull).
The "*" set excludes the HITL interrupt tools (ask_human, request_user_input) alongside execute_code. Add GET /api/mcp/exposed so the exposed set is introspectable (today only the consumed MCP is listed).
D. HTTP API tightening (breaking, aliased one release)
- Namespaces: collapse
goal→goals(member =/api/goals/{session_id}); unify the memory store under/api/memory/*withknowledgechunks andinjectionsas sub-resources (/api/memory/chunks,/api/memory/injections). Keep the old paths as 307-aliases with aDeprecationheader for one minor release, then delete. /v1: populate realusage(prompt/completion/total) from the turn's token accounting (the same numberscost-v1reports over A2A) so OpenAI-SDK clients + cost tooling work.- Operation catalog: a generated
GET /api/operations(andprotoagent config operations) enumerating every D2 op with its params + which surfaces expose it — the "one operation, three projections" made introspectable, and the source the CLI help + MCP tool list derive from.
E. Local-LLM onboarding + "use in app"
protoAgent already accepts any OpenAI-compatible api_base, so onboarding is discovery + a one-liner, not new model plumbing:
- Discovery —
protoagent setup(andprotoagent model discover) probe well-known local endpoints — Ollama (http://localhost:11434/v1,/api/tags), LM Studio (:1234/v1), vLLM / HF TGI (/v1/models) — and list the models each serves (mirrors proto'smodelDiscovery). protoagent modelverbs —list(configured + discovered),use(the load-bearing one:protoagent model use --base-url http://127.0.0.1:8080/v1 --model <id>setsapi_base+model_name+ a placeholder key via the config op, non-interactively, no gateway required),add/remove(named endpoints),pull <name>(shell out toollama pullwhen present). Must tolerate the HF quant-tag placeholder:(default to a sensible quant).- "Use in app" handoff — a HuggingFace local-app registration, not a deep-link (research 2026-07-05). protoAgent is an OpenAI-compatible agent runtime, so it registers exactly like
hermes-agent/openclaw/pi: a ~20-line PR topackages/tasks/src/local-apps.tsinhuggingface/huggingface.js, gated onisToolCallingLocalAgentModel(GGUF/MLX +conversational- a tools chat-template), emitting a 3-step snippet (not a
deeplink): (1)getLocalServerStepstarts llama.cpp/MLX on:8080/v1, (2)protoagent model use --base-url http://127.0.0.1:8080/v1 --model ${modelId}, (3)protoagentto launch. Once merged it shows on every compatible model card's "Use this model" dropdown. Ollama offers no such registry — it's OpenAI-API-only (:11434/v1), reached by the sameprotoagent model use. Aprotoagent://deep-link is only worth adding if we ship a GUI to foreground (optional, secondary). The prereqs the snippet needs are exactly this ADR's PR1 (installable command) + D5 (model useone-liner).
- a tools chat-template), emitting a 3-step snippet (not a
- Leverage the HF quant reputation, not social clout. Two compounding surfaces: (A) the registry PR puts protoAgent on other people's compatible cards; (B) — works today, no HF dependency — the team's own published quant model cards carry a "Run with protoAgent" snippet and
conversational+ tools-template metadata, so they auto-surface protoAgent the moment the PR merges. Converts at the moment of intent (a user on the card about to run the model).
F. Install / distribution
Both, binary-first for the headline:
- Zero-Python — the existing PyInstaller frozen binary (the desktop build) shipped as
protoagentbehindcurl -fsSL https://…/install.sh | sh(mirroring proto'sinstall.sh: detect arch/OS, fetch, put on PATH, runprotoagent setup). Also a Homebrew tap. - Python present —
uv tool install protolabs-agent/pipx install protolabs-agent(the PyPI name;protoagentis similarity-blocked) → theprotoagentconsole_scriptsentry on PATH.
G. Auth
Unchanged. The CLI talks to a local instance with the resolved Bearer (from config/env); the MCP admin profiles ride the same default-deny middleware and the ADR 0066 ceiling. No new credential model.
Rollout
- PR1 — the
protoagentCLI (D1): entrypoint + subparser tree +--help, re-parent the five existing dispatchers, addup/down/status/serve/setup. Low risk (≈80% repackaging); immediately kills the "boringpython -m server" front door. - PR2 — the
ops/layer + MCP admin profiles (D2 + D3): extract the shared ops, wire the three adapters, add profiles + theask_humanexclusion +GET /api/mcp/exposed. - PR3 — API tightening (D4): namespace dedupe (aliased),
/v1usage,/api/operationscatalog. - PR4 — model onboarding (D5): discovery +
protoagent model+ the install scripts; theprotoagent://deep-link + "use in app" as a fast-follow once its payload contract lands.
Install/distribution (F) rides PR1 (binary + uv tool) and PR4 (the install.sh polish).
Consequences
- One terminal identity.
protoagent up && protoagent plugin install … && protoagent fleet upreplaces a grab-bag ofpython -m server,uv, and docker — and it's discoverable via--help. - "Operate over MCP" becomes true — a foreign brain can administer a protoAgent within an explicit, consented profile, not just chat with it.
- The surfaces stop drifting — a new operation is one
ops/function that appears on all three surfaces by construction, listed in one catalog. - Breaking API changes land once (aliased a release) instead of accreting a third idiom.
- Local models are a click — the Ollama/HF ecosystems can hand a model to protoAgent.
- Cost: a real
ops/seam + adapters is more indirection than three ad-hoc surfaces; the payoff is the anti-drift guarantee.
Open questions
- HF local-app PR — acceptance + gate choice (D5). (Resolved mechanism, open on execution.) The registration is a PR to
huggingface.jslocal-apps.ts(review ≈ 1–2 wk, no formal bar, GGUF/MLX is the ticket). Open: use the narrowisToolCallingLocalAgentModelgate (the honest agent-runtime precedent — reviewers will steer here) vs. the broadisLlamaCppGgufModel(all ~45K GGUF cards, but over-reaching draws a change request)? And do we also ship a GUIprotoagent://deep-link, or snippet-only like the other three agent runtimes (snippet-only)? - CLI ↔ running-server vs on-disk ops. Some ops act on disk (plugin install), some need a live server (fleet status, hot config). Does the CLL auto-boot a transient server for live-only ops, or require
protoagent upfirst? (Lean: disk ops work headless; live ops error with "runprotoagent up" unless--ensure-up.) Resolved (2026-09, fleet deck S0 — #3467): live hub first, badged disk fallback, never a transient server. Everyfleetverb and the deck look for a running hub first — this instance'sserver.pid, then the.instances/heartbeats under every box root on the machine (the desktop app's included), then:7870— and act through it. Only when nothing answers do they read this instance'sfleet.json, and the output is badgedoffline · reading <fleet.json>so a shell can never mistake disk for the live fleet. A hub that answered but could not be opened (rejected credential, timeout, 5xx, a member answering as a fleet of itself) is an error, not a fallback — driving processes from disk beside a running hub is the two-hubs bug the rule exists to prevent. The one place the deck opens anyway isfleet --all(the hub tree is the view that shows a hub that refused this shell); its roster is then the disk view, badged with the refusal, with start/stop refused. No--ensure-up:protoagent upis the operator's explicit act. fullMCP profile blast radius. A consented foreign brain doingfleet up/ work assignment is powerful; do we cap it (rate/scope) beyond the consent ack, or trust the ADR 0071 posture?- Homebrew/npm namespace.
protoagenton Homebrew + a possible@protolabsai/protoagentthin npm shim (soprotousers discover it) — worth it, or binary +uvonly?
Alternatives considered
- Fold runtime management into
proto(the first instinct) — rejected:protois a Qwen-fork terminal coding agent with its own identity; bolting a Python-runtime control plane onto it conflates two products and two audiences ("trips ourselves up"). The clean line is:prototalks-to / is-driven-by protoAgent over the wire;protoagentruns and manages the runtime. - Keep
python -m server <sub>— rejected: undiscoverable, un-installable, reads as an internal, and blocks the "one command" onboarding story. - One giant unified registry replacing REST + CLI + MCP — rejected as over-reach: the two existing spines (tool registry,
graph/**cores) are already sound; the fix is a thinops/seam over the admin spine + faithful projections, not a rewrite. - A LiteLLM-gateway-only model story — rejected for local onboarding: the gateway stays the default, but requiring it for "point at my Ollama" is friction the direct
api_basepath already avoids.