Skip to content

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.yml builds and rolls the image on merge to main (paths workspace_agent/**, k8s/workspace-agent/**).
  • Ingress: done (2026-09-04). The routing for mit.nonlocally.org is now declared in this repo as k8s/ingress.yaml (#532, exported from the live GCE Ingress) with /acp and /ws/acp → workspace-agent:8000; applied the same day. Measured after GCLB reconciled: scripts/probe_acp.py --url wss://mit.nonlocally.org/acpverdict: ACP OK (upgrade 101, protocolVersion 1, agentInfo openweb-marimo-workspace-agent 1.2.0, session/new ok). A change to the path rules is a PR against k8s/ingress.yaml plus a kubectl 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.