Skip to content

Patterns / Agent screen

Show a computer-use or browser agent's screen

An agent that uses a computer or a browser should be watchable. AgentScreen shows its screen from what your app passes: an image URL for the latest frame, a video URL, or a MediaStream. Beside the frame it shows what the agent is doing now, as text, and that text is also the frame's alternative for a screen reader.

Nothing is drawn until a frame arrives. While connection is "connecting" it says so, so a person never mistakes a placeholder for the screen. Pass frameAt, the time the frame was captured, and a frame older than staleAfter is stamped "No new frame since 10:42". Without frameAt Candor doesn't judge how fresh the picture is. A lost connection or an ended session keeps the last frame and says it is the last one.

The simplest source is a frame per action. With a computer-use tool, your own code carries out each action and takes the screenshot the model asked for; store that image, and send the page its URL, the time it was taken and the action in your own words. Server-sent events are enough for that. For a continuous picture of a remote browser, use WebRTC and pass the MediaStream from the peer connection's track event.

Control, Pause, Stop and recording come from your app's state and go back through callbacks. Take over hands the screen to a person and Hand back returns it. Pause and Stop each say what happens to the step the agent is part-way through, in your words through pauseNote and stopNote, or say that you haven't said. Recording says what is recorded before it starts, says when it is about to start, and stays marked while it runs, collapsed or expanded.

The components

  • Agent screenProThe screen, who has control, Pause and Stop with what happens to the step in flight, recording, and a stale-frame stamp (Pro).
  • Run heartbeatProThe run's own state, which says when nothing has been heard from it rather than calling it running (Pro).
  • Stop allProPausing every agent in the workspace at once, with what happens to the runs in flight (Pro).
  • Side panelProWhere the collapsed screen can sit beside the thread (Pro).

See it working

The code

The latest frame from your server's event stream, with the connection's state.

TSX · use-screen-frames.ts
"use client"

import * as React from "react"
import type { AgentScreenConnection, AgentScreenSource } from "@/components/candor/agent-screen"

type Frame = { url: string; at: string; action: string }

/** The latest frame of a run's screen, from your server's event stream. */
export function useScreenFrames(runId: string) {
  const [frame, setFrame] = React.useState<Frame>()
  const [connection, setConnection] = React.useState<AgentScreenConnection>("connecting")

  React.useEffect(() => {
    const events = new EventSource(`/api/runs/${runId}/screen`)
    events.addEventListener("frame", (e) => {
      setFrame(JSON.parse((e as MessageEvent<string>).data))
      setConnection("live")
    })
    events.addEventListener("end", () => {
      setConnection("ended")
      events.close()
    })
    // EventSource tries again by itself. Until a frame arrives, the screen says the connection was lost.
    events.onerror = () => setConnection((c) => (c === "ended" ? c : "lost"))
    return () => events.close()
  }, [runId])

  const source: AgentScreenSource | undefined = frame && { kind: "image", src: frame.url }
  return { source, connection, frameAt: frame?.at, action: frame?.action }
}

A route that sends a frame event for each screenshot and an end event when the session closes.

TSX · app/api/runs/[id]/screen/route.ts
import { canWatchRun, onScreenFrame } from "@/lib/runs" // your own

export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  // Frames show whatever the agent can see: check the viewer as you would for the run itself.
  if (!(await canWatchRun(request, id))) return new Response(null, { status: 404 })

  const encoder = new TextEncoder()
  const body = new ReadableStream({
    start(controller) {
      const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))
      const stop = onScreenFrame(id, {
        // `capturedAt` is when the screenshot was taken on the agent's machine, not when it reached the server.
        frame: (f) => send("frame", { url: `/api/runs/${id}/frames/${f.n}`, at: f.capturedAt, action: f.action }),
        end: () => {
          send("end", {})
          controller.close()
        },
      })
      request.signal.addEventListener("abort", stop)
    },
  })
  return new Response(body, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" } })
}

A remote browser over WebRTC: the peer connection's track and its state.

TSX · use-screen-stream.ts
"use client"

import * as React from "react"
import type { AgentScreenConnection } from "@/components/candor/agent-screen"

const connectionOf = (state: RTCPeerConnectionState): AgentScreenConnection =>
  state === "connected" ? "live" : state === "closed" ? "ended" : state === "failed" || state === "disconnected" ? "lost" : "connecting"

/** A remote browser's screen over WebRTC: the peer connection is yours, set up with your own signalling. */
export function useScreenStream(pc: RTCPeerConnection) {
  const [stream, setStream] = React.useState<MediaStream>()
  const [connection, setConnection] = React.useState<AgentScreenConnection>(() => connectionOf(pc.connectionState))

  React.useEffect(() => {
    const onTrack = (e: RTCTrackEvent) => setStream(e.streams[0])
    const onState = () => setConnection(connectionOf(pc.connectionState))
    pc.addEventListener("track", onTrack)
    pc.addEventListener("connectionstatechange", onState)
    return () => {
      pc.removeEventListener("track", onTrack)
      pc.removeEventListener("connectionstatechange", onState)
    }
  }, [pc])

  return { source: stream && { kind: "stream" as const, stream }, connection }
}

AgentScreen wired to the frames and to your run's own state and calls.

TSX · run-screen.tsx
"use client"

import { AgentScreen } from "@/components/candor/agent-screen"
import { useScreenFrames } from "./use-screen-frames"
import { useRun } from "@/lib/use-run" // your own: the run's state and the calls that change it

export function RunScreen({ runId }: { runId: string }) {
  const screen = useScreenFrames(runId)
  const run = useRun(runId)
  return (
    <AgentScreen
      agent={run.agent}
      {...screen}
      control={run.controlledBy === "agent" ? "agent" : "person"}
      person={run.controlledBy === "viewer" || run.controlledBy === "agent" ? undefined : run.controlledBy}
      onTakeOver={run.takeOver}
      onHandBack={run.controlledBy === "viewer" ? run.handBack : undefined}
      run={run.state}
      onPause={run.pause}
      onResume={run.resume}
      onStop={run.stop}
      pauseNote="It finishes the action it's on, then waits. Nothing else is clicked or typed."
      stopNote="It stops after the action it's on. Pages it opened stay as they are; nothing is undone."
      recording={run.recording}
      recordingSince={run.recordingSince}
      recordingNote="The screen is recorded, with no sound. Recordings are kept for 30 days and your team can watch them."
      onRecord={run.startRecording}
      onStopRecording={run.stopRecording}
    />
  )
}

Install

With the candor-ui CLI

Each component brings in what it depends on. The free ones install as they are; Pro and Premium ones need a licence key.

Good to know

  • Left to the app: capturing the screen, storing frames, the signalling for WebRTC, who may watch a run, pausing the agent while a person has control, and storing recordings. Candor sends no input to the remote screen. After Take over, the person uses whatever control surface your app provides, such as a remote desktop view.
  • Frames show everything the agent can see, which can include personal data. Serve them from your own origin behind the same check as the run, and keep them no longer than you need.
  • Say in recordingNote what is recorded, how long it is kept and who can watch it. Whether you need the agreement of the people whose data appears on screen is for you to decide.
  • Set frameAt from the machine that took the screenshot, not from when it reached your server, so the stamp says how old the picture really is.