23 KiB
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:
- Create a chat session on the member's OpenWork Web cloud worker.
- Send prompts to it.
- Read back status and transcript.
- 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:
- Member identity comes from the already-verified MCP auth context
(
auth.ts) — never from arguments. - Resolve the worker exactly as den-gateway does (
/v1/cloud/gateway/resolve) via a shared internal helper; do not duplicate resolution logic. - Call openwork-server using the
packages/headless-threadsclient (or its underlying HTTP shape) with the resolved worker URL + token. - Map worker errors to gateway error vocabulary: no worker provisioned →
actionable "needs cloud worker" result (relay the human step, mirroring
needs_admin_setup/needs_signinconventions), 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.tsconventions). - Org capability flag: Cloud is default-off per organization
(
metadata.capabilities.cloud,cloud-rollout.ts). When the flag is off, the source is invisible insearch_capabilitiesand execute reportsunknown_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_setupis reserved for the member-facing "open OpenWork Cloud once to provision" case. - Member-scoped only: capabilities operate on the caller's resolved worker.
sessionIdfrom 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:
- A built-in remote skill ("send this to my web chat") teaching the flow —
builtin-skills.ts, no client release needed. - 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.mjspollsGET /v1/automation-runner/workevery 60 s (wake-on-resume viapowerMonitor), claims runs, heartbeats every 10 s (heartbeat response carriescancelRequested+ 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 viaPOST /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}andexecuteDesktopAutomationruns it as a normal visible local session (POST /workspace/:id/opencode/session), returningsessionId/workspaceIdinto 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, andsupportedExecutionTargets(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):
- 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_sessiontasks get their own table and lifecycle vocabulary (queued/claimed/running/interrupted/done— no "missed occurrence"). Runner token capabilities gain aremote_sessiongrant next to automation grants so org policy can allow one without the other. The work kind is negotiated through the existingcapabilities/protocolVersionhandshake. - Runner handler: in
automation-runner.mjs, aremote_sessiontask executes exactly like an automation run (visible session titled from the request), except follow-upremoteSession.sendtasks resume the same localsessionIdinstead of creating a new one. - 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_availablenotifications) — the runner just doesn't consume it yet. ConsumingeventsPathis the single change that makes desktop dispatch near-real-time, and it benefits Automations too. - 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 explicitrunnerId(fromremoteSession.list) to pin a machine. No runner in the presence window → actionable "open OpenWork Desktop and sign in" result, never a silent queue-forever. - Transcript reads: Den cannot query the desktop, so
remoteSession.readfor 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 throughsearch_capabilities/execute_capability. - New rich result UI →
registerAgentXxxApphelper + renderer inpackages/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 retryablewakingstate; 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
powerMonitorresume/unlock, so delivery after lid-open is seconds, not the 60 s poll interval. - Opt-in queueing: an explicit
deliverBytimestamp on the task enables store-and-forward for handoff-style work ("run on my desktop whenever it's online today"). A task unclaimed pastdeliverBytransitions toexpiredwith a clear state readable viaremoteSession.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
- Phase 1 — capability source + worker routing + text results. Proof:
testkit spec in
evals/specs/driving desktop engine → gateway → mock worker (witness for openwork-server, perbuild-a-witness), asserting session creation, prompt receipt, transcript read, and member-scoping (cross-member 404). - 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). - Phase 3 —
target: "desktop": generalize the runner seam server-first (generic/v1/desktop-runner/*endpoints with/v1/automation-runner/*aliases, dedicatedremote_sessiontask table), then add theremote_sessionwork kind + runner handler inautomation-runner.mjs, negotiated viaprotocolVersion/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 inlistAutomationRuns. - 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: falseboundary 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.