# Remote Chat over MCP — sessions on another surface via the OpenWork MCP Gateway Status: proposal Owner: TBD Related: `docs/marketplace-capabilities-architecture.md`, `ee/apps/den-api/src/mcp/README.md` Covers two execution targets behind the same gateway capabilities: `target: "cloud"` (session on the member's OpenWork Web cloud worker) and `target: "desktop"` (session on one of the member's signed-in desktop apps, dispatched over the existing Desktop Automation runner channel). ## Problem OpenWork Desktop and OpenWork Web are both live chat surfaces, but they cannot talk to each other. A member working in a desktop chat has no way to start or continue a conversation that runs on their OpenWork Web cloud worker — for example to hand long-running work to the cloud, to make a session visible to teammates in the browser, or to keep work running after the laptop closes. At the same time, the MCP gateway (`/mcp/agent`) is the sanctioned way to extend what agents can do org-wide, and desktop already attaches it to every workspace engine. Anything we build as a gateway capability works from desktop **and** from every external MCP client (Claude Code, Codex, Cursor) for free. ## Goal From a desktop chat (or any MCP client connected to the gateway), the agent can: 1. Create a chat session on the member's OpenWork Web cloud worker. 2. Send prompts to it. 3. Read back status and transcript. 4. Hand the member a link/card to open that session live in OpenWork Web. Non-goals (v1): - Streaming token-by-token output back over MCP (poll/read instead). - Cross-member or cross-org sessions. Everything is scoped to the signed-in member's own worker. - A new chat UI. OpenWork Web renders the session with its existing UI because the session is a native openwork-server session — no sync layer needed. ## What exists today (reused, not rebuilt) | Piece | Where | Role in this design | | --- | --- | --- | | MCP gateway | `ee/apps/den-api/src/mcp/agent.ts` | Hosts `search_capabilities` / `execute_capability`; new work plugs in behind them | | Capability registry | `ee/apps/den-api/src/mcp/capability-registry.ts` | Fan-out to capability sources — **the extension point** | | Worker resolution | den-api `/v1/cloud/gateway/resolve` (used by `ee/apps/den-gateway/src/app.ts`) | Maps a member to their cloud openwork-server instance + auth | | openwork-server session API | `apps/server` (`POST /workspace/:id/opencode/session`, native prompt/transcript/status routes) | The actual chat runtime on the worker | | Programmatic session client | `packages/headless-threads` | Typed client for driving native sessions from code | | Desktop → gateway attach | `apps/app/src/react-app/domains/connections/cloud-mcp-reconciler.ts` + `apps/server/src/routes/cloud-mcp.ts` (token mint: `POST /v1/mcp/token`) | Desktop engines already have `/mcp/agent`; zero desktop changes required for v1 | | MCP App cards | `ee/apps/den-api/src/mcp/connection-action-app.ts` pattern + `packages/mcp-apps` | Render an "Open in OpenWork Web" card | ## Architecture ``` Desktop chat (engine) den-api (/mcp/agent) Cloud worker (openwork-server) ───────────────────── ──────────────────── ────────────────────────────── agent calls capability-registry search_capabilities ───────────────▶ └─ remote-session source execute_capability │ resolve member worker "remoteSession.create" ───────────▶ │ (cloud/gateway/resolve) ──▶ POST /workspace/:id/opencode/session "remoteSession.send" ─────────────▶ │ ───▶ POST /workspace/:id/opencode/session/:id/prompt_async "remoteSession.read" ─────────────▶ │ ───▶ GET native session/message/todo/status routes └ returns result + MCP App card linking to OpenWork Web OpenWork Web (apps/app via den-gateway) shows the session live — same server, no sync ``` Key property: the "web MCP" is not a second MCP server. The gateway is the single MCP surface; OpenWork Web sees the session because the session lives on the same openwork-server the web UI already reads through den-gateway. ### 1. New capability source: `remote-session-capabilities.ts` New file in `ee/apps/den-api/src/mcp/`, registered in `capability-registry.ts` alongside `builtin-skills.ts` / `native-capabilities.ts`. It contributes capabilities to `search_capabilities` and handles their execution — **not** new top-level MCP tools, keeping the gateway's small-constant-tool-list contract intact. Capabilities (names indicative): | Capability | Args | Behavior | | --- | --- | --- | | `remoteSession.create` | `{ target: "cloud" \| "desktop", title?, workspaceId?, instructions?, runnerId? }` | Cloud: resolve member worker, create session. Desktop: enqueue a claimable task for the member's runner (§6). Returns `{ sessionId \| taskId, target, webUrl? }` | | `remoteSession.send` | `{ sessionId, prompt }` | Cloud: `prompt_async` to the session. Desktop: append a follow-up task bound to the same local session. Returns a receipt `{ messageId, state: "accepted" }` | | `remoteSession.read` | `{ sessionId, since? }` | Session status + transcript slice (bounded, paginated). Cloud: read from worker. Desktop: read from Den-side task events (§6) | | `remoteSession.list` | `{ target? }` | Member's remote sessions; for desktop also lists runner presence per machine | Execution path inside the source: 1. Member identity comes from the already-verified MCP auth context (`auth.ts`) — never from arguments. 2. Resolve the worker exactly as den-gateway does (`/v1/cloud/gateway/resolve`) via a shared internal helper; do not duplicate resolution logic. 3. Call openwork-server using the `packages/headless-threads` client (or its underlying HTTP shape) with the resolved worker URL + token. 4. Map worker errors to gateway error vocabulary: no worker provisioned → actionable "needs cloud worker" result (relay the human step, mirroring `needs_admin_setup` / `needs_signin` conventions), never a fake success. ### 2. Async model `prompt_async` returns immediately; the agent polls with `remoteSession.read`. This matches MCP's request/response grain and avoids holding gateway connections open. If later we want push, the gateway can add MCP resource subscriptions on a `remote-session://` resource (pattern already exists: `automation-index.ts`, `resource.ts`) — explicitly out of scope for v1. ### 3. MCP App card `remoteSession.create` and `remoteSession.send` results include a standard MCP Apps `ui://` card (pattern: `connection-action-app.ts` + `packages/mcp-apps` renderer): session title, state, last activity, and an **Open in OpenWork Web** link (`https://web.openworklabs.com/...` deep link, resolved from runtime config — same origin den-web's "Web tab" uses). Clients without MCP Apps get text fallback with the same URL. ### 4. Policy and scopes - Gate the whole source behind a scope in `ee/apps/den-api/src/mcp/scopes.ts` (e.g. `remote-session`), included in first-party desktop tokens (`/v1/mcp/token`) and public OAuth tokens by default; org policy can turn it off via the existing exposure allowlist mechanics (`policy.ts` conventions). - **Org capability flag**: Cloud is default-off per organization (`metadata.capabilities.cloud`, `cloud-rollout.ts`). When the flag is off, the source is invisible in `search_capabilities` and execute reports `unknown_capability` — mirroring the external-MCP rollout pattern, so members of a flag-off org never see an action they cannot take. The runtime re-checks the flag live at execute time (`cloud_not_available`, an admin-facing action) as defense in depth against mid-session flag flips; `needs_cloud_setup` is reserved for the member-facing "open OpenWork Cloud once to provision" case. - Member-scoped only: capabilities operate on the caller's resolved worker. `sessionId` from another member's worker must 404, not 403-leak. - Prompts transit the gateway but are stored only on the worker (same trust domain as existing web chat). No prompt content in gateway logs; log receipts/ids only. ### 5. Desktop side (v1: zero code) Desktop engines already carry the `openwork-cloud` MCP entry. The agent discovers `remoteSession.*` via `search_capabilities` naturally. Optional polish, in order: 1. A built-in remote skill ("send this to my web chat") teaching the flow — `builtin-skills.ts`, no client release needed. 2. Desktop UI affordance ("Continue in Web") that pre-fills the prompt — later, once the capability path is proven. ### 6. Desktop execution target — reuse the Automation runner channel Web → desktop communication already exists: Desktop-placed Automations. Den never reaches into a desktop; the signed-in desktop app pulls work over an authenticated channel and reports back. That channel is task-agnostic in everything but naming, and `target: "desktop"` reuses it instead of inventing a second dispatch path. What ships today (all reused): - **Runner client:** `apps/desktop/electron/automation-runner.mjs` polls `GET /v1/automation-runner/work` every 60 s (wake-on-resume via `powerMonitor`), claims runs, heartbeats every 10 s (heartbeat response carries `cancelRequested` + lease validity), posts ordered events, and completes with a durable receipt. - **Runner identity/auth:** per-machine `runnerId` (persisted UUID) + HMAC runner token (12 h TTL, audience-bound to the minting Den base URL), minted by the renderer with member auth via `POST /v1/automation-runners/token` (`automation-runner-bridge.tsx`). The mint route is `"x-mcp": false` — MCP callers can never obtain runner credentials. This stays true here: the gateway capability only **enqueues tasks**; it never touches runner auth. - **Execution shape:** the assignment is already `{instructions, model, timeoutMs}` and `executeDesktopAutomation` runs it as a **normal visible local session** (`POST /workspace/:id/opencode/session`), returning `sessionId` / `workspaceId` into the Den receipt. Exactly the semantics we want: remote work on desktop is a real thread the human can watch and take over. - **Built-in extension points:** runner registration declares `protocolVersion`, `capabilities`, and `supportedExecutionTargets` (`packages/types/src/automations.ts`), so a new work kind is a versioned, negotiated addition — old runners simply never see it. Flow: ``` Any MCP client den-api Desktop app (Electron main) ────────────── ─────── ─────────────────────────── execute_capability remoteSession.create ──▶ enqueue remote-session task {target:"desktop"} (claimable, deadline-bound, same tables/protocol as desktop automation runs) GET /v1/automation-runner/work ← task {kind:"remote_session", …} claim → lease → create visible local session → heartbeat → POST events (assistant text, sessionId, usage, terminal) remoteSession.read ────▶ read task events from Den ◀──────POST complete {receipt} (Den never contacts desktop) ``` **Reuse boundary — promote the channel, don't squat on it.** The runner *protocol* (claim/lease/heartbeat/events/complete), runner *identity/auth*, and *presence* are task-agnostic and shared. But automation **run tables, run history, receipts, and schedule-native lifecycle states** (missed occurrence, claim deadline bounded by next occurrence) are automation semantics — `remote_session` tasks must NOT be stored as automation runs or appear in `listAutomationRuns`. The channel is generalized into a desktop task dispatch seam on which automations are one work kind, not the landlord. Deltas required (small, additive): 1. **Work kind on a generalized seam, not a new channel**: extract the claim/lease/heartbeat/events/complete protocol + runner auth/presence as a shared layer. Server-first migration: introduce generic runner endpoints (e.g. `/v1/desktop-runner/*`) and keep `/v1/automation-runner/*` as compatible aliases — the runner ships inside Electron, so old clients live for a long time. `remote_session` tasks get their own table and lifecycle vocabulary (`queued/claimed/running/interrupted/done` — no "missed occurrence"). Runner token capabilities gain a `remote_session` grant next to automation grants so org policy can allow one without the other. The work kind is negotiated through the existing `capabilities` / `protocolVersion` handshake. 2. **Runner handler:** in `automation-runner.mjs`, a `remote_session` task executes exactly like an automation run (visible session titled from the request), except follow-up `remoteSession.send` tasks resume the same local `sessionId` instead of creating a new one. 3. **Latency:** the 60 s work poll is fine for fire-and-forget handoff but sluggish for conversation. The low-latency seam is already built and tested server-side: the SSE stream `GET /v1/automation-runners/events` (`automation_work_available` notifications) — the runner just doesn't consume it yet. Consuming `eventsPath` is the single change that makes desktop dispatch near-real-time, and it benefits Automations too. 4. **Machine selection:** a member may have several desktops. Default to the most-recently-seen runner within the presence window (`GET /v1/automation-runners/presence`, 10 min window); allow explicit `runnerId` (from `remoteSession.list`) to pin a machine. No runner in the presence window → actionable "open OpenWork Desktop and sign in" result, never a silent queue-forever. 5. **Transcript reads:** Den cannot query the desktop, so `remoteSession.read` for desktop targets reads the ordered task events the runner posted (assistant text, state, `sessionId`) — same durable store automation run receipts use. Full-fidelity transcript stays on the desktop; events carry the summary + final output. Security invariants preserved from the automation channel: - Runner tokens: first-party mint only, audience-bound, owner-revocable, instantly dead if the member leaves the org. MCP can enqueue, never run. - Same-member only: tasks are scoped `{organizationId, ownerMemberId}`; a gateway caller can only dispatch to their **own** desktops. Cross-member dispatch is a non-goal and must 404. - Desktop execution stays **visible** (a real thread), consistent with the deliberate automation-runner decision — no headless remote control of a member's machine. - Model access is re-checked at claim time, same as automation claims. ### 7. Extending the gateway generally (the reusable recipe) This feature doubles as the template for future gateway extensions: - **New discoverable behavior** → new capability source file registered in `capability-registry.ts`, gated by a scope, surfaced through `search_capabilities` / `execute_capability`. - **New rich result UI** → `registerAgentXxxApp` helper + renderer in `packages/mcp-apps`. - **New top-level MCP tool** → only when the tool must exist without discovery (rare; requires updating every client's mental model). Avoid. ## Offline and delivery semantics The two targets are asymmetric: cloud runtimes can be booted on demand; desktops cannot. ### Cloud target — auto-wake, bounded wait, retryable state "Web not running" usually means the member's cloud worker is idle-stopped (Den stops healthy workers after 30 min idle, `stopIdleCloudWorkers`), not that anything is missing — the browser being closed never matters, since the session runtime is the worker and the web UI is just a viewer. - Existing behavior reused: resolving a stopped worker fire-and-forget wakes it and reports `"waking"` (`resolveCloudInstance` → `wakeCloudWorker`, provision deadline 15 min, silent reprovision if the sandbox is gone). Cloud automation runs additionally await the wake and poll readiness at 1 s intervals up to 120 s (`cloud-agent-executor.ts`). - `remoteSession.create` / `send {target:"cloud"}`: trigger the wake, poll readiness within a short in-call budget (~20–30 s to keep the MCP call responsive), otherwise return a **retryable `waking` state**; the agent retries the same idempotent call. No durable queue — the wake is already in flight server-side. - No worker provisioned at all (vs. stopped) → actionable needs-setup result, same as the failure-modes table. ### Desktop target — fail fast by default, opt-in store-and-forward Den cannot power on a machine and never contacts the desktop; the runner pulls. Therefore: - **Default: fail fast.** No runner inside the 10-min presence window → `remoteSession.create {target:"desktop"}` returns the exact human action ("open OpenWork Desktop on and sign in"). Conversations are not silently queued against machines that may be off for days. - **Sleeping ≠ offline.** A running app on a sleeping laptop wakes and polls immediately on `powerMonitor` resume/unlock, so delivery after lid-open is seconds, not the 60 s poll interval. - **Opt-in queueing:** an explicit `deliverBy` timestamp on the task enables store-and-forward for handoff-style work ("run on my desktop whenever it's online today"). A task unclaimed past `deliverBy` transitions to `expired` with a clear state readable via `remoteSession.read` — analogous to missed desktop automation runs, but expressed in the remote-session lifecycle vocabulary, never silently dropped. ## Failure modes | Failure | Behavior | | --- | --- | | Member has no cloud worker | `remoteSession.create` returns an actionable needs-setup result naming the exact human step (provision worker in Den dashboard) | | Worker unreachable / asleep | Retryable error with `referenceId`; no partial session creation | | Session deleted on web | `remoteSession.send`/`read` return `unknown_session`, prompting `remoteSession.list` | | Token expiry mid-flow | Standard gateway 401 semantics; desktop reconciler already auto-refreshes | | No desktop runner in presence window | `remoteSession.create {target:"desktop"}` returns actionable "open OpenWork Desktop and sign in on " — never queues forever | | Desktop task unclaimed past deadline | Marked missed with cause, same as missed desktop automation runs; surfaced via `remoteSession.read` | | Desktop goes offline mid-task | Lease expires (60 s heartbeat loss) → task marked interrupted with last posted events preserved | ## Rollout 1. **Phase 1** — capability source + worker routing + text results. Proof: testkit spec in `evals/specs/` driving desktop engine → gateway → mock worker (witness for openwork-server, per `build-a-witness`), asserting session creation, prompt receipt, transcript read, and member-scoping (cross-member 404). 2. **Phase 2** — MCP App card + built-in skill. Proof: MCP App render spec (pattern: existing `mcp-app-render-test`), plus e2e where the session created from desktop appears in headless web UI (`worlds/dev-headless.ts`). 3. **Phase 3** — `target: "desktop"`: generalize the runner seam server-first (generic `/v1/desktop-runner/*` endpoints with `/v1/automation-runner/*` aliases, dedicated `remote_session` task table), then add the `remote_session` work kind + runner handler in `automation-runner.mjs`, negotiated via `protocolVersion`/`capabilities`. Proof: testkit spec with a fake runner exercising claim/heartbeat/events/complete for the new kind, member-scoping, no-runner-present, and an assertion that remote-session tasks never appear in `listAutomationRuns`. 4. **Phase 4** — runner consumes the existing SSE stream (`/v1/automation-runners/events`) for near-real-time dispatch (shared win with Automations); external clients doc page (Claude Code/Codex get both targets with no extra work); optional MCP resource subscriptions for live transcript updates. ## Alternatives considered - **Desktop talks to the worker directly (connect-link handoff), skipping the gateway.** Fewer hops, but only works from OpenWork Desktop, duplicates worker resolution/auth in the client, and bypasses org policy. Rejected. - **New top-level MCP tools (`create_remote_chat`, `send_to_session`).** Breaks the gateway's deliberate two-tool discovery contract and bloats every connected client. Rejected in favor of a capability source. - **A new dedicated cloud→desktop channel (WebSocket/push) for desktop targets.** The automation runner channel already provides claim/lease/ heartbeat/receipts with hardened runner auth, and its SSE wake-up stream is already built server-side. A parallel channel would duplicate all of it and double the desktop's attack surface. Rejected — extend the work-kind schema instead. - **Letting MCP callers mint or hold runner tokens to talk to desktops "directly".** Violates the deliberate `x-mcp: false` boundary on `/v1/automation-runners/token`; the gateway must only enqueue tasks. Rejected. - **A durable-execution engine (Temporal, Inngest) for dispatch and delivery.** The genuine unlock (long-lived multi-step sagas with timers and signals) does not exist in this design: every operation is either request-scoped (cloud wake-and-poll, <2 min) or edge-pull (the desktop runner cannot be an engine worker behind NAT — the claim/lease protocol stays bespoke regardless). Den's durability model is already crash-safe state machines in Postgres (worker status as cross-replica mutex, claimable rows + deadline sweeps, durable receipts), so an engine would be a re-platform, not an enhancement — and it adds a second source of truth beside the receipt tables users read, plus heavy self-host burden for OSS deployers (own cluster/datastore for Temporal; second runtime for Inngest) and Temporal's determinism/versioning tax. Rejected for this design. Escalation path if pressure appears: (1) Postgres-native queue (pg-boss / graphile-worker) — no new vendor, ships with the existing DB; (2) only if the Workflows product ever needs true sagas, an internal orchestration seam with Postgres as the default implementation and an engine as an optional adapter, so self-deploy stays vanilla Postgres. - **A separate MCP server hosted by OpenWork Web.** There is no web MCP server today (den-web is dashboard-only); building one duplicates auth, policy, and exposure logic the gateway already owns. Rejected.