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— missingsessionscapability. Grant the required workspace scope.404— workspace or focus target is unavailable to the caller. Reconcile the slug or agent.402,409,410, or lifecycle503. Surface the workspace lifecycle action and honorRetry-Afterwhen present.429— the authenticated principal exceeded an API limit. Wait forRetry-After, then retry safely.503withdesktop_unavailable. The live viewer-grant registry is full; wait at least one second and retry minting.502withdesktop_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 /v1 /ws /{workspaceSlug} /desktop /sessionMint 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
/v1 /ws /{workspaceSlug} /desktop /statusRead 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
/v1 /ws /{workspaceSlug} /agents /{agentSlug} /desktop /focusFocus 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