392 lines
23 KiB
Markdown
392 lines
23 KiB
Markdown
# 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 <machine> 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 <machine>" — 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.
|