ACP endpoint on mit.nonlocally.org (#507)¶
An Agent Client Protocol agent, served by the
workspace-agent deployment, that turns any Open WebUI user's personal API key
into an ACP session backed by that site's own chat pipeline. Built for the boxed
photonics freelancer (phido-glm53-box) to attach as a session; usable by any
ACP client.
| URL | wss://mit.nonlocally.org/acp (alias wss://mit.nonlocally.org/ws/acp) |
| Transport | JSON-RPC 2.0 over WebSocket text frames, one message per frame, newline-terminated (ndjson-compatible) |
| Auth | Authorization: Bearer <personal Open WebUI API key> on the upgrade request. ?token=<key> also works for clients that cannot set headers, but a key in a URL can end up in load-balancer and proxy access logs, so use the header wherever possible. A bad key is refused before the upgrade completes (HTTP 403). initialize advertises no authMethods. |
| Model | glm (GLM-5.3-Flash through LiteLLM, tools on) by default; any model id the caller can see, via session/new _meta.model |
| Tools | the model preset's saved default tools (meta.toolIds), passed the way Open WebUI's own channel handler passes them. Sessions run in Open WebUI's legacy function-calling mode by default, which is the mode where Open WebUI itself executes the tools on the plain API path (measured: lean_compile ran, answer streamed). _meta.function_calling: "native" returns the model's raw tool calls instead; nothing executes them on this path, so they arrive as tool_call updates marked failed. |
| Source | workspace_agent/acp.py; tests tests/unit/test_acp_endpoint.py; probe scripts/probe_acp.py |
Status¶
- Code, tests, manifest, probe: in this repo (PR for #507).
- Deployment:
.github/workflows/workspace-agent-production.ymlbuilds and rolls the image on merge tomain(pathsworkspace_agent/**,k8s/workspace-agent/**). - Ingress: done (2026-09-04). The routing for
mit.nonlocally.orgis now declared in this repo ask8s/ingress.yaml(#532, exported from the live GCE Ingress) with/acpand/ws/acp → workspace-agent:8000; applied the same day. Measured after GCLB reconciled:scripts/probe_acp.py --url wss://mit.nonlocally.org/acp→verdict: ACP OK(upgrade 101,protocolVersion 1,agentInfo openweb-marimo-workspace-agent 1.2.0,session/newok). A change to the path rules is a PR againstk8s/ingress.yamlplus akubectl apply— an owner checkpoint, since it is user-facing.
How the ingress is declared¶
k8s/ingress.yaml carries every path rule the host serves (the OWUI SPA at /, marimo,
the MCP server, the showcase, the DT companions, and now ACP). tests/unit/test_ingress_manifest.py
pins the ACP rules and that every backend Service is declared somewhere under k8s/.
kubectl diff -f k8s/ingress.yaml against the cluster is the drift check; it was empty right
after the apply.
The Service already carries cloud.google.com/neg: '{"ingress":true}' and binds
BackendConfig workspace-agent-backend-config (timeoutSec: 3600), so a prompt
turn longer than the GCLB default 30 s is not cut. Both objects are applied by
the production workflow.
Protocol subset¶
Implemented per ACP v1:
| Method | Behaviour |
|---|---|
initialize |
returns protocolVersion: 1, agentCapabilities.loadSession: false, promptCapabilities {image: true, audio: false, embeddedContext: true}, authMethods: [] |
session/new |
returns sessionId; cwd is accepted and ignored; mcpServers are ignored (the agent runs inside the platform and cannot reach servers on the client's machine; _meta.mcpServersIgnored says so). Options via _meta: model (any id the caller can see), function_calling (legacy default, or native) |
session/prompt |
streams session/update notifications with sessionUpdate: "agent_message_chunk" (one messageId per turn), then answers with stopReason end_turn, max_tokens, refusal, or cancelled |
session/cancel (notification) |
stops the in-flight turn; the pending session/prompt answers cancelled; the partial reply stays in the session history |
authenticate |
no-op success (auth already happened at the upgrade) |
session/load, session/set_mode, session/set_model |
-32601 |
Content blocks accepted in prompts: text, resource (text embedded as a
<resource uri=...> block), resource_link (as a one-line reference), image
(forwarded as a data URL; glm has vision). audio is rejected.
Session history is kept in memory for the life of the WebSocket (last 60 messages per session, 16 sessions per connection, 8 connections per user, one in-flight prompt per session). Nothing is written to disk; the caller's key is forwarded to Open WebUI and never logged.
Tool activity in legacy mode is not surfaced as ACP tool_call updates: Open
WebUI runs the tools before the model answers and streams only the answer (with
[n] source markers), so the box sees the outcome as text. Open WebUI's own
tool events go through its Socket.IO emitter, which needs a saved chat; wiring
that up (a real chat per ACP session, events mapped onto tool_call /
tool_call_update) is the natural follow-up and would also make each session
visible in the user's chat list.
Probe¶
scripts/probe_acp.py is stdlib only (its own minimal WebSocket client). It
runs initialize -> session/new -> session/prompt and prints a JSON record
with the negotiated version, the session id, the streamed chunks, and the stop
reason. The key is read from OPENWEBUI_API_KEY or --token-file; it is never
printed.
# local, against a workspace-agent started with OPENWEBUI_URL=https://mit.nonlocally.org
OPENWEBUI_API_KEY=... python3 scripts/probe_acp.py --url ws://127.0.0.1:8000/acp
# production, once the ingress path exists
OPENWEBUI_API_KEY=... python3 scripts/probe_acp.py --url wss://mit.nonlocally.org/acp
# a prompt that needs a tool (legacy mode runs it inside Open WebUI)
OPENWEBUI_API_KEY=... python3 scripts/probe_acp.py --url ... \
--prompt "Use the lean_compile tool on: theorem t : 1 + 1 = 2 := rfl, then say in one line whether it compiled"
Verdict ACP OK means a real model reply streamed back through the protocol.
SPA means the ingress still serves the Open WebUI app at that path. 403
means the key was refused.
Measured¶
tests/data/acp_probe.json, 2026-09-03, workspace-agent on a laptop with
OPENWEBUI_URL=https://mit.nonlocally.org, so everything but the production
ingress path is the real thing:
| Run | Result |
|---|---|
plain prompt, glm |
ACP OK, reply streamed, end_turn, under 1 s |
| tool prompt, legacy mode (default) | ACP OK, Open WebUI ran lean_compile, reply "It compiled successfully with no errors or warnings [1]", 23.5 s |
| tool prompt, native mode | ACP OK, model's raw call to compile_lean surfaced as tool_call then tool_call_update (failed, not executed), model text streamed |
The production record is added once the ingress rule exists.