Browse documentationDesktop

Resource guide

Embed the shared workspace desktop

The Desktop API exposes the same live framebuffer that a person sees in Duet. Your backend mints an origin-bound viewer URL, your browser embeds it, and either side can read status or ask Duet to bring an agent's existing Chrome window to the front. The API does not create a second desktop or an isolated browser.

Desktop routes require ws:{workspaceSlug}:sessions. Ordinary API keys, OAuth tokens, installed app credentials, and Duet's browser session all use the same public operations. Keep a long-lived credential on your backend; send only the short-lived viewer response to browser code.

Put the credential behind your own session

Mount the checked-in Web-standard handler at /api/desktop/* in your backend framework. Its authorize callback represents your application's own login and workspace authorization—do not publish an unauthenticated mint proxy. The workspace slug and Duet API key come from server configuration rather than browser input.

Shared example types
export interface DesktopScreen {
  width: 1440
  height: 900
}

export interface DesktopSession {
  entryUrl: string
  expiresAt: number
  phase: 'stopped' | 'starting' | 'ready'
  screen: DesktopScreen
}

export interface DesktopStatus {
  phase: DesktopSession['phase']
  viewers: number
  owners: Array<{
    owner: { kind: 'root' } | { kind: 'agent'; agentId: string }
    agentSlug?: string
    agentName?: string
    phase: 'active' | 'cooling' | 'idle'
    focused: boolean
    liveBatchCount: number
    batchTabCount: number
    discardedBatchTabCount: number
    lastActiveAt: number
  }>
}

export interface DesktopFocusResult {
  focused: boolean
}

export type DesktopFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
Backend handler
import type { DesktopFetch } from './desktop-contract'

export interface DesktopBackendOptions {
  duetApiUrl: string
  apiKey: string
  workspaceSlug: string
  authorize(request: Request): boolean | Promise<boolean>
  fetch?: DesktopFetch
}

/** Mount this Web-standard handler at /api/desktop/* in your server framework. */
export function createDesktopBackend(options: DesktopBackendOptions) {
  const duetFetch = options.fetch ?? fetch
  const workspace = encodeURIComponent(options.workspaceSlug)

  return async function handleDesktopRequest(request: Request): Promise<Response> {
    if (!(await options.authorize(request))) {
      return Response.json({ error: 'unauthorized' }, { status: 401 })
    }

    const { pathname } = new URL(request.url)
    if (request.method === 'POST' && pathname === '/api/desktop/session') {
      const body = await readObject(request)
      if (typeof body?.embedOrigin !== 'string') return invalidRequest()
      return await duet(`/v1/ws/${workspace}/desktop/session`, {
        method: 'POST',
        body: JSON.stringify({ embedOrigin: body.embedOrigin }),
      })
    }

    if (request.method === 'GET' && pathname === '/api/desktop/status') {
      return await duet(`/v1/ws/${workspace}/desktop/status`)
    }

    if (request.method === 'POST' && pathname === '/api/desktop/focus') {
      const body = await readObject(request)
      if (typeof body?.agentSlug !== 'string' || typeof body.requestId !== 'string') {
        return invalidRequest()
      }
      return await duet(
        `/v1/ws/${workspace}/agents/${encodeURIComponent(body.agentSlug)}/desktop/focus`,
        { method: 'POST', body: JSON.stringify({ requestId: body.requestId }) },
      )
    }

    return Response.json({ error: 'not_found' }, { status: 404 })
  }

  async function duet(path: string, init: RequestInit = {}): Promise<Response> {
    const response = await duetFetch(`${options.duetApiUrl.replace(/\/$/, '')}${path}`, {
      ...init,
      headers: {
        authorization: `Bearer ${options.apiKey}`,
        ...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
      },
    })
    const headers = new Headers({
      'cache-control': 'no-store',
      'content-type': response.headers.get('content-type') ?? 'application/json',
    })
    const retryAfter = response.headers.get('retry-after')
    if (retryAfter !== null) headers.set('retry-after', retryAfter)
    return new Response(response.body, { status: response.status, headers })
  }
}

async function readObject(request: Request): Promise<Record<string, unknown> | null> {
  try {
    const body: unknown = await request.json()
    return typeof body === 'object' && body !== null && !Array.isArray(body)
      ? (body as Record<string, unknown>)
      : null
  } catch {
    return null
  }
}

function invalidRequest(): Response {
  return Response.json({ error: 'invalid_request' }, { status: 400 })
}

The handler forwards the Duet status and error body plus Retry-After, but never returns the API key. It also marks responses no-store, because entryUrl is a bearer capability: anyone who has that URL can attempt admission until it expires. Redact the complete URL from logs, analytics, traces, exception reports, browser history, and support transcripts.

Mint once, then fit the real screen

The browser sends its exact window.location.origin to the backend. Duet accepts one canonical HTTPS origin, or HTTP only for localhost, 127.0.0.0/8, and [::1]. A path, trailing slash, query, fragment, credentials, wildcard, non-default spelling of a default port, or a list of origins is invalid. Duet binds the resulting grant to that exact origin with Content Security Policy; loading the URL from another origin is refused by the browser's frame policy, not by a separate REST 403.

{
  "embedOrigin": "https://customer.example"
}
{
  "entryUrl": "https://vm.example/d/g.cap/",
  "expiresAt": 1786723200000,
  "phase": "ready",
  "screen": { "width": 1440, "height": 900 }
}

Use the response's screen, which is the VM's actual framebuffer resolution, as the iframe's intrinsic size. Uniformly scale it down to fit the container, never enlarge it beyond 1×, and center the unused space. This preserves pointer coordinates and aspect ratio instead of asking the desktop to reflow like a web page.

The iframe policy is exact: sandbox is allow-same-origin allow-scripts allow-forms allow-modals allow-pointer-lock allow-downloads, allow is clipboard-read; clipboard-write, fullscreen is enabled, and referrerpolicy="no-referrer" prevents the capability URL from becoming another site's referrer.

Browser mount, scaling, status, and focus
import type {
  DesktopFocusResult,
  DesktopFetch,
  DesktopScreen,
  DesktopSession,
  DesktopStatus,
} from './desktop-contract'

export const DESKTOP_IFRAME_SANDBOX =
  'allow-same-origin allow-scripts allow-forms allow-modals allow-pointer-lock allow-downloads'
export const DESKTOP_IFRAME_ALLOW = 'clipboard-read; clipboard-write'
export const DESKTOP_IFRAME_REFERRER_POLICY = 'no-referrer'
export const DESKTOP_IFRAME_ALLOW_FULLSCREEN = true

export function createDesktopBrowserApi(fetchImpl: DesktopFetch = fetch) {
  return {
    mint: (embedOrigin: string) =>
      requestJson<DesktopSession>(fetchImpl, '/api/desktop/session', {
        method: 'POST',
        body: JSON.stringify({ embedOrigin }),
      }),
    status: () => requestJson<DesktopStatus>(fetchImpl, '/api/desktop/status'),
    focus: (agentSlug: string, requestId: string) =>
      requestJson<DesktopFocusResult>(fetchImpl, '/api/desktop/focus', {
        method: 'POST',
        body: JSON.stringify({ agentSlug, requestId }),
      }),
  }
}

/** Mint once on mount, remint after an actual reconnect, and poll status separately. */
export async function mountDesktop(
  container: HTMLElement,
  options: {
    onStatus?(status: DesktopStatus): void
    onError?(error: unknown): void
    statusIntervalMs?: number
  } = {},
) {
  const api = createDesktopBrowserApi()
  const iframe = document.createElement('iframe')
  iframe.title = 'Workspace desktop'
  iframe.referrerPolicy = DESKTOP_IFRAME_REFERRER_POLICY
  iframe.sandbox.value = DESKTOP_IFRAME_SANDBOX
  iframe.allow = DESKTOP_IFRAME_ALLOW
  iframe.allowFullscreen = DESKTOP_IFRAME_ALLOW_FULLSCREEN
  iframe.style.position = 'absolute'
  iframe.style.border = '0'
  iframe.style.transformOrigin = 'top left'

  container.style.position = 'relative'
  container.style.overflow = 'hidden'
  container.style.background = 'black'
  container.replaceChildren(iframe)

  let screen: DesktopScreen = { width: 1440, height: 900 }
  let disposed = false
  let reconnecting: Promise<void> | null = null

  const layout = () => {
    const rect = fitDesktop(
      { width: container.clientWidth, height: container.clientHeight },
      screen,
    )
    iframe.width = String(screen.width)
    iframe.height = String(screen.height)
    iframe.style.left = `${rect.left}px`
    iframe.style.top = `${rect.top}px`
    iframe.style.transform = `scale(${rect.scale})`
  }
  const reconnect = () => {
    if (reconnecting !== null) return reconnecting
    reconnecting = api
      .mint(window.location.origin)
      .then(session => {
        if (disposed) return
        screen = session.screen
        iframe.src = session.entryUrl
        layout()
      })
      .finally(() => {
        reconnecting = null
      })
    return reconnecting
  }
  const refreshStatus = async () => {
    const status = await api.status()
    options.onStatus?.(status)
    return status
  }
  const focus = (agentSlug: string, requestId: string) => api.focus(agentSlug, requestId)
  const reportError = (error: unknown) => options.onError?.(error)
  const reconnectWhenOnline = () => void reconnect().catch(reportError)

  await reconnect()
  const resizeObserver = new ResizeObserver(layout)
  resizeObserver.observe(container)
  const statusTimer = window.setInterval(() => {
    void refreshStatus().catch(reportError)
  }, options.statusIntervalMs ?? 5_000)
  window.addEventListener('online', reconnectWhenOnline)

  return {
    reconnect,
    refreshStatus,
    focus,
    dispose() {
      disposed = true
      resizeObserver.disconnect()
      window.clearInterval(statusTimer)
      window.removeEventListener('online', reconnectWhenOnline)
      iframe.remove()
    },
  }
}

export function fitDesktop(container: { width: number; height: number }, screen: DesktopScreen) {
  const scale = Math.min(1, container.width / screen.width, container.height / screen.height)
  const width = screen.width * scale
  const height = screen.height * scale
  return {
    left: (container.width - width) / 2,
    top: (container.height - height) / 2,
    width,
    height,
    scale,
  }
}

async function requestJson<T>(
  fetchImpl: DesktopFetch,
  path: string,
  init: RequestInit = {},
): Promise<T> {
  const response = await fetchImpl(path, {
    ...init,
    headers: init.body === undefined ? undefined : { 'content-type': 'application/json' },
  })
  if (!response.ok) {
    const error = new Error(`Desktop request failed: ${response.status}`)
    Object.assign(error, { response })
    throw error
  }
  return (await response.json()) as T
}

The example mints on mount and after the browser reports that the network is back online. It does not replace the iframe on a timer. expiresAt is the last moment a new HTTP or WebSocket admission may begin, not a lease on an admitted connection: a viewer WebSocket admitted before expiry stays connected until it disconnects. If the page remounts, the iframe reloads, or the viewer actually disconnects, mint a fresh URL. Do not cache and reuse an expired URL.

Status polling is independent from viewer admission. Polling status may update lifecycle or owner UI, but it must not tear down a healthy iframe.

Status describes one shared machine

{
  "phase": "ready",
  "viewers": 1,
  "owners": []
}

phase is stopped, starting, or ready; viewers counts admitted viewers. Each owner entry describes root or one named agent, whether its browser window is focused, its activity phase, and current or discarded batch-tab counts.

Every viewer and agent shares one framebuffer, input stream, clipboard, Chrome profile, and global native focus. A person's click can race an agent's input, and the last focus or interaction wins. Owner labels organize presentation and resources; they are not security boundaries. Anyone trusted inside one workspace VM may see other tabs and windows.

Focus is idempotent presentation intent

Focus brings an existing browser window for root or a named agent to the top. It does not create a window, select a particular tab, or isolate what other viewers can see. If an agent needs a particular tab shown, it first selects that tab through its browser-control connection, then calls focus for the owning window.

Generate one requestId per logical focus attempt and reuse it if that request must be retried. Changing the ID means a new attempt.

{
  "requestId": "login-019ffcd5"
}
{
  "focused": true
}

focused: false means no existing window for that owner was brought forward; minting a viewer does not implicitly create or focus one.

Handle refusals without leaking the capability

All desktop errors use JSON. Preserve the status, body, and Retry-After header while keeping entryUrl out of diagnostics.

  • 400 — invalid mint or focus body. Correct the request; do not retry unchanged.
  • 401 — missing, invalid, expired, or revoked credential. Reauthenticate the backend.
  • 403 — missing sessions capability. Grant the required workspace scope.
  • 404 — workspace or focus target is unavailable to the caller. Reconcile the slug or agent.
  • 402, 409, 410, or lifecycle 503. Surface the workspace lifecycle action and honor Retry-After when present.
  • 429 — the authenticated principal exceeded an API limit. Wait for Retry-After, then retry safely.
  • 503 with desktop_unavailable. The live viewer-grant registry is full; wait at least one second and retry minting.
  • 502 with desktop_gateway_failed. Use bounded backoff and recheck status.

The general authenticated ceiling is 600 requests in a fixed 60-second window. API, OAuth, app, and sync-device credentials each have their own credential bucket; browser sessions and impersonation share a user bucket. Existing stricter endpoint limits still apply. See idempotency and rate limits for retry behavior and errors for the common wire shape.

Endpoint schemas

Desktop

3 operations
POST/v1/ws/{workspaceSlug}/desktop/session

Mint a short-lived, origin-bound viewer URL for the shared workspace desktop.

Scope
ws:{workspaceSlug}:sessions
Request
Request JSON schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "embedOrigin": {
      "type": "string"
    }
  },
  "required": [
    "embedOrigin"
  ],
  "additionalProperties": false
}
Response
Response JSON schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "entryUrl": {
      "type": "string",
      "format": "uri"
    },
    "expiresAt": {
      "type": "integer",
      "exclusiveMinimum": 0,
      "maximum": 9007199254740991
    },
    "phase": {
      "type": "string",
      "enum": [
        "stopped",
        "starting",
        "ready"
      ]
    },
    "screen": {
      "type": "object",
      "properties": {
        "width": {
          "type": "number",
          "const": 1440
        },
        "height": {
          "type": "number",
          "const": 900
        }
      },
      "required": [
        "width",
        "height"
      ],
      "additionalProperties": false
    }
  },
  "required": [
    "entryUrl",
    "expiresAt",
    "phase",
    "screen"
  ],
  "additionalProperties": false
}
Errors
Error body JSON schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "anyOf": [
    {
      "anyOf": [
        {
          "type": "object",
          "properties": {
            "error": {
              "anyOf": [
                {
                  "type": "string",
                  "enum": [
                    "unauthorized",
                    "not_found",
                    "desktop_gateway_failed"
                  ]
                },
                {
                  "type": "string",
                  "enum": [
                    "workspace_paused",
                    "workspace_provisioning",
                    "workspace_unhealthy",
                    "workspace_archived"
                  ]
                }
              ]
            }
          },
          "required": [
            "error"
          ],
          "additionalProperties": false
        },
        {
          "type": "object",
          "properties": {
            "error": {
              "type": "string",
              "const": "insufficient_scope"
            },
            "scope": {
              "type": "string",
              "minLength": 1
            }
          },
          "required": [
            "error",
            "scope"
          ],
          "additionalProperties": false
        }
      ]
    },
    {
      "type": "object",
      "properties": {
        "error": {
          "type": "string",
          "const": "invalid_request"
        }
      },
      "required": [
        "error"
      ],
      "additionalProperties": false
    },
    {
      "type": "object",
      "properties": {
        "error": {
          "type": "string",
          "const": "desktop_unavailable"
        }
      },
      "required": [
        "error"
      ],
      "additionalProperties": false
    }
  ]
}
Delivery
Standard response
Retry
Not declared idempotent
GET/v1/ws/{workspaceSlug}/desktop/status

Read the shared desktop lifecycle, viewer count, and browser-owner activity.

Scope
ws:{workspaceSlug}:sessions
Request
No JSON request body
Response
Response JSON schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "phase": {
      "type": "string",
      "enum": [
        "stopped",
        "starting",
        "ready"
      ]
    },
    "viewers": {
      "type": "integer",
      "minimum": 0,
      "maximum": 9007199254740991
    },
    "owners": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "owner": {
            "oneOf": [
              {
                "type": "object",
                "properties": {
                  "kind": {
                    "type": "string",
                    "const": "root"
                  }
                },
                "required": [
                  "kind"
                ],
                "additionalProperties": false
              },
              {
                "type": "object",
                "properties": {
                  "kind": {
                    "type": "string",
                    "const": "agent"
                  },
                  "agentId": {
                    "type": "string",
                    "minLength": 1
                  }
                },
                "required": [
                  "kind",
                  "agentId"
                ],
                "additionalProperties": false
              }
            ]
          },
          "agentSlug": {
            "type": "string",
            "minLength": 1
          },
          "agentName": {
            "type": "string",
            "minLength": 1
          },
          "phase": {
            "type": "string",
            "enum": [
              "active",
              "cooling",
              "idle"
            ]
          },
          "focused": {
            "type": "boolean"
          },
          "liveBatchCount": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          },
          "batchTabCount": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          },
          "discardedBatchTabCount": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          },
          "lastActiveAt": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991
          }
        },
        "required": [
          "owner",
          "phase",
          "focused",
          "liveBatchCount",
          "batchTabCount",
          "discardedBatchTabCount",
          "lastActiveAt"
        ],
        "additionalProperties": false
      }
    }
  },
  "required": [
    "phase",
    "viewers",
    "owners"
  ],
  "additionalProperties": false
}
Errors
Error body JSON schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "anyOf": [
    {
      "type": "object",
      "properties": {
        "error": {
          "anyOf": [
            {
              "type": "string",
              "enum": [
                "unauthorized",
                "not_found",
                "desktop_gateway_failed"
              ]
            },
            {
              "type": "string",
              "enum": [
                "workspace_paused",
                "workspace_provisioning",
                "workspace_unhealthy",
                "workspace_archived"
              ]
            }
          ]
        }
      },
      "required": [
        "error"
      ],
      "additionalProperties": false
    },
    {
      "type": "object",
      "properties": {
        "error": {
          "type": "string",
          "const": "insufficient_scope"
        },
        "scope": {
          "type": "string",
          "minLength": 1
        }
      },
      "required": [
        "error",
        "scope"
      ],
      "additionalProperties": false
    }
  ]
}
Delivery
Standard response
Retry
Not declared idempotent
POST/v1/ws/{workspaceSlug}/agents/{agentSlug}/desktop/focus

Focus an existing root or named-agent browser window without creating one.

Scope
ws:{workspaceSlug}:sessions
Request
Request JSON schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "requestId": {
      "type": "string",
      "minLength": 1,
      "maxLength": 128
    }
  },
  "required": [
    "requestId"
  ],
  "additionalProperties": false
}
Response
Response JSON schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "focused": {
      "type": "boolean"
    }
  },
  "required": [
    "focused"
  ],
  "additionalProperties": false
}
Errors
Error body JSON schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "anyOf": [
    {
      "anyOf": [
        {
          "type": "object",
          "properties": {
            "error": {
              "anyOf": [
                {
                  "type": "string",
                  "enum": [
                    "unauthorized",
                    "not_found",
                    "desktop_gateway_failed"
                  ]
                },
                {
                  "type": "string",
                  "enum": [
                    "workspace_paused",
                    "workspace_provisioning",
                    "workspace_unhealthy",
                    "workspace_archived"
                  ]
                }
              ]
            }
          },
          "required": [
            "error"
          ],
          "additionalProperties": false
        },
        {
          "type": "object",
          "properties": {
            "error": {
              "type": "string",
              "const": "insufficient_scope"
            },
            "scope": {
              "type": "string",
              "minLength": 1
            }
          },
          "required": [
            "error",
            "scope"
          ],
          "additionalProperties": false
        }
      ]
    },
    {
      "type": "object",
      "properties": {
        "error": {
          "type": "string",
          "const": "invalid_request"
        }
      },
      "required": [
        "error"
      ],
      "additionalProperties": false
    }
  ]
}
Delivery
Standard response
Retry
Declared idempotent