How the pieces fit

Five services. The interesting question is not what each one does — that is in Reference: services — but why they are not one program.

The answer is the same every time: each boundary exists because something must not cross it.

The one boundary everything else serves

The model key and workspace access are never held by the same process.

The orchestrator has the model key and the conversation. It has no Nextcloud client, and adding one is the single change that would quietly end the guarantee this system exists to make. Workspace access rides on the user's own token, forwarded per request, and is spent by a different service.

The obvious simplification — let the orchestrator call Nextcloud directly and drop the tool server — would work on the first day and would turn "the agent can only see what the user can see" from a consequence of the structure into a claim somebody has to audit.

The split buys something practical too. Because MCP spec 2026-07-28 dropped protocol-level sessions, one stateless process serves every user: the credential arrives with the request instead of living in the environment. The stdio predecessor needed one process per user, which is why it was retired.

Why the PHP app is in the path at all

It would be simpler for the browser to call the orchestrator directly. It cannot, for a reason that is about trust rather than routing: the only thing that knows who the user is, is Nextcloud.

The PHP app is the one component with a Nextcloud session. It reads the uid from IUserSession — never from anything the client sent — and signs it, together with a timestamp and a hash of the request body, before passing it on. Nothing downstream re-derives an identity, and a uid arriving in a request body is rejected with a 400 rather than ignored, because a body uid is an attempt to choose one.

Mail takes the same door from the other direction. mcp-mail calls back into the PHP app rather than speaking IMAP, so no mail credential exists anywhere in this stack — Nextcloud Mail owns the account, and swapping Gmail for Exchange is a change in its settings and no code at all.

The path of one message

Two properties of this path break silently when disturbed.

The stream is proxied, not buffered. PHP's CURLOPT_RETURNTRANSFER collects the whole body, which turns a token-by-token stream into one late blob. So the response implements Nextcloud's ICallbackResponse and flushes each chunk through. If a reply ever appears all at once in the browser while the orchestrator logs show it streaming, PHP is buffering again.

Identity is decided once and signed. See G1.

Two names for the same server, twice

This catches everyone, and it catches them twice because it appears in two unrelated places.

ThingBrowser-facingCompose-internal
OnlyOfficeDocumentServerUrllocalhost:8083DocumentServerInternalUrlhttp://onlyoffice
Nextcloud, seen by the agentoverwritehostlocalhost:8080NEXTCLOUD_URLhttp://nextcloud

Swapping either pair fails without an error worth reading. For OnlyOffice the editor stops loading. For the agent, every OIDC token is rejected with a bare 401 — user_oidc compares the token's iss against the discovery document's issuer exactly, and those two values come from different hostnames unless overwritehost forces them to agree.

Two paths that are not the chat

Both were added after the agent worked, and both are shaped by a measured constraint rather than a preference.

A document becomes text. The OnlyOffice document server converts only a file it fetches itself, by URL, and it holds no credential of the user's. So the tool server downloads the file as the user — that is where RBAC applies, as always — and lends it back on a one-shot URL: 256-bit token, single use, 60 seconds, in memory, compose-internal. A temporary public share link would have been the easy answer, and it is forbidden here because it mutates the user's account.

A tool's result renders itself. The chat cannot inline an app's HTML: an iframe on a local scheme inherits the embedder's CSP, and Nextcloud's script-src-elem 'strict-dynamic' 'nonce-…' then blocks the app's own script. That was measured in a browser, not deduced. So the PHP app re-serves the HTML from a same-origin URL under a policy built from the document itself.

Where a change lands

Two seams, and nothing else moves.

  • A new workspace capability → one file in mcp-nextcloud/src/modules/ exporting register(server, ctx), plus one line in server.js. → Add a tool
  • A new source of tools — another MCP server, a local tool, a deterministic workflow → orchestrator/src/tools/registry.ts, and only there.

agent/ imports nothing from tools/mcp/, db/ or context/sections/. It receives a ToolSet and a ContextEngine as arguments. That import rule is what makes the sentence above structural rather than aspirational.