EN / field notes OpenHands feature map

OpenHands / F26

Launcher modes, Docker, desktop and library

Agent Canvas ships one UI in several runtime shapes. The npm launcher (npx @openhands/agent-canvas, here node bin/agent-canvas.mjs) runs the full stack, or only the frontend or only the backends, prints its version and stack info, refuses conflicting flags, and decides whether the session key is injected into the page (loopback) or the user must paste it (--public, or a non-loopback --host). The same stack also ships as a Docker image, an Electron desktop app and an embeddable React library. Launchers tell the agent which services exist through /server_info.runtime_services and a <RUNTIME_SERVICES> block in every new conversation's system prompt.

32 mapped behaviors · 28 recipes and supporting checks · source snapshot 9 October 2026
From upstream main at 8793c111. Read the maintained source.

How to get to it

  • A terminal: npx @openhands/agent-canvas [flags]; from this checkout, node bin/agent-canvas.mjs [flags] (what control-openhands launch starts with --port, and --public for public mode).
  • The browser at the ingress URL the launcher prints (http://localhost:8000/ by default).
  • Backend selector (backend-selector) → Add Backend (add-backend-menu-item) → Agent-server → Remote, and Manage Backends to remove it (see F25 for the backend registry itself).
  • Public mode and the API-key screen are mapped in F01 (F01.api-key-entry, F01.onboarding-backend-step).
  • npm run build:lib for the embeddable library; a host app imports @openhands/agent-canvas (see docs/DEVELOPMENT.md, "Cloud organization recovery in embedded hosts").
  • docker run ... ghcr.io/openhands/agent-canvas or npm run build:docker, then http://localhost:8000/canvas.
  • helm install agent-canvas ./helm/agent-canvas on a Kubernetes cluster (helm/agent-canvas/README.md).
  • npm run desktop (development) or a packaged build from npm run build:desktop; the window opens itself.
  • ACP agents (Claude Code, Codex, Gemini CLI) are chosen in onboarding (F01) and Settings → Agents (F13); this family does not repeat them.

Before you start

Start with the common launch and health checks, then follow this family’s preconditions in order. Recipes share the fixtures and state named below.

Preconditions:

  • Baseline state (launched, doctored, onboard --skip done) on your own run: control-openhands launch --new --build never, export OH_VERIFY_RUN=<run from the launch JSON>, control-openhands doctor, control-openhands onboard --skip. Commands run from the checkout root.
  • F26.desktop-* need the Electron binary (node_modules/electron/dist/electron; npx --no-install electron --version downloads it on first use) and Xvfb plus ImageMagick import for headless capture. The app binds the fixed ports 8000, 18000, 18001 and 3001: check them like the blocks below and keep the run short.
  • F26.runtime-services and F26.runtime-services-agent-use need an active LLM profile (control-openhands llm preset deepseek); the second also needs the automation service running and costs two short runs (the conversation and the dispatched automation run).
  • The partial-stack and LAN bullets start extra launchers by hand (harness gap: launch has no --frontend-only, --backend-only or --host). Each one needs a free block of ports: these recipes use 18960–18963 and 18970–18973, plus 19961, the editor port a launcher that serves VS Code hands its agent-server (see Gotchas); check them first with for p in 18960 18961 18962 18963 18970 18971 18972 18973 19961; do (echo > /dev/tcp/127.0.0.1/$p) 2>/dev/null && echo "$p busy"; done (no output means free). Each launcher gets a private HOME and state under $OH_VERIFY_RUN/private/, so it never touches ~/.openhands or your run.

Behavior inventory

32 stable behavior IDs and their expected behavior
  • F26.cli-version --version and -v print the package version and exit 0. Read recipe ↓
  • F26.cli-info --info prints the package version, the default agent-server and automation pins, the minimum agent-server version, the default ports and the override env vars. Read recipe ↓
  • F26.cli-help --help prints usage, auth modes, options, env vars and examples. Read recipe ↓
  • F26.cli-flag-conflicts --frontend-only with --backend-only, and --public with --frontend-only, exit 1 with a one-line error before anything starts. Read recipe ↓
  • F26.cli-public-needs-key --public without LOCAL_BACKEND_API_KEY exits 1 with guidance. Read recipe ↓
  • F26.cli-missing-build a package without build/ exits 1 with No build found and build instructions. Read recipe ↓
  • F26.cli-port-in-use an occupied ingress (or service) port stops the launch with Cannot start: the following ports are already in use naming each busy port. Read recipe ↓
  • F26.loopback-bind-default without --host the ingress listens on 127.0.0.1 only: the machine's own non-loopback address refuses the connection while loopback serves the app. Read recipe ↓
  • F26.session-key-rotated in local mode a stale session key left in the browser (the launcher's key changed since the last visit) is replaced by the injected key on load: the app opens on Home without the API-key screen, and the stored Local backend holds the new key (public mode asks for it instead: F01's api-key-entry row). Read recipe ↓
  • F26.session-key-persist in local mode without LOCAL_BACKEND_API_KEY the launcher generates a session key, saves it under $HOME/.openhands/agent-canvas/api-key.txt and injects the same key again on the next start. Read recipe ↓
  • F26.session-key-pinned LOCAL_BACKEND_API_KEY=<key> in local mode injects exactly that key into the page and the API accepts only it (the saved generated key gets 401). Read recipe ↓
  • F26.frontend-only --frontend-only serves the SPA; /server_info, /api/*, /sockets/* and /api/automation/* answer 503, and a browser new to that origin opens on the Add a backend onboarding step without an error toast. Read recipe ↓
  • F26.frontend-only-returning on a frontend-only origin, a browser that already stores a backend for that origin (the default-local entry a full launcher on the same port seeded, or one added there earlier) gets the recovery gate instead of onboarding: agent-server-onboarding-screen with Manage backends in recovery mode and the Local row Disconnected with the 503 detail (the gate itself is F25's recovery-gate row); the failing probes raise generic An error occurred toasts there (Known failure, reproduced 2026-10-08: #18160). Read recipe ↓
  • F26.backend-only --backend-only serves the APIs (key-protected) and answers 503 No backend configured for this route for / and static assets. Read recipe ↓
  • F26.cross-connect a frontend-only UI connects to a separate backend-only instance through Add a backend; the shell then shows that backend as Connected and all API traffic goes to it. Read recipe ↓
  • F26.remote-backend from a normal Canvas, backend selector → Add Backend → Agent-server → Remote shows the self-hosting guidance and connects to another instance (stand-in for a self-hosted VM, docs/SELF_HOSTING.md). Read recipe ↓
  • F26.seeded-local-backend the first load of a launcher-served UI seeds one backend Local (id default-local) with the page origin and the injected key. Read recipe ↓
  • F26.host-bind-lan --host 0.0.0.0 warns, listens on the machine's LAN address, and serves an index.html that carries __AGENT_CANVAS_AUTH_REQUIRED__ and neither __AGENT_CANVAS_SESSION_API_KEY__ nor the key itself (the loopback launcher's page carries the key marker); the UI behaves like --public (Add a backend with Next disabled; Skip leads to the API-key screen). Read recipe ↓
  • F26.host-bind-lan-optin the opt-in the launcher's warning recommends (--allow-lan-session-key) should restore key injection on a LAN bind. Known failure (reproduced 2026-10-08): the npm launcher ignores the flag and the page still carries only __AGENT_CANVAS_AUTH_REQUIRED__; #17949. Read recipe ↓
  • F26.host-bind-env OH_BIND_HOST=0.0.0.0 (the environment form of --host) prints the same two "not loopback" warnings and does not inject the session key, even with LOCAL_BACKEND_API_KEY set. Read recipe ↓
  • F26.runtime-services /server_info.runtime_services lists the agent-server, ingress, frontend and automation URLs as the agent sees them, and a new conversation's system prompt carries a matching <RUNTIME_SERVICES> block. Read recipe ↓
  • F26.runtime-services-agent-use with that block alone, the agent can reach the automation backend from its terminal: asked to, it reads the OpenAPI at the URL the block names, creates an enabled cron automation, and dispatches a run that completes; the automation and its run show up in the Automations UI. Read recipe ↓
  • F26.lib-build npm run build:lib produces dist/ where every package.json exports target exists and the main entry exports AgentServerUIProviders, AgentServerUIRoot, CloudOrganizationBoundary and the telemetry helpers. Read recipe ↓
  • F26.lib-style-scope all bundled CSS is scoped under [data-agent-server-ui], and theme tokens such as --oh-color-base live on that scope root (observable in the standalone app). Read recipe ↓
  • F26.lib-host-app mounted in a separate host app, Canvas styles stay inside the scope and styleOverrides restyle it. Blocked: no host-app example exists. Read recipe ↓
  • F26.docker-image docker run ghcr.io/openhands/agent-canvas serves Canvas at /canvas; without AGENT_CANVAS_ALLOW_LAN_SESSION_KEY=true the UI asks for the key. Blocked without a Docker daemon. Known failure (reproduced 2026-10-08): on a host whose kernel has no IPv6 (no /proc/net/if_inet6), docker/entrypoint.sh starts the static server with --host ::, which exits with listen EAFNOSUPPORT: address family not supported :::8000, and the container stops within about half a minute of start, with exit code 0 (#18178). Read recipe ↓
  • F26.docker-conversation-runtime OH_CONVERSATION_RUNTIME=docker runs each new conversation in its own container. Blocked without a Docker daemon. Read recipe ↓
  • F26.helm-chart helm install agent-canvas ./helm/agent-canvas runs the Docker image as a Kubernetes StatefulSet with a PVC and an Ingress. Blocked without helm and a cluster. Read recipe ↓
  • F26.desktop-boot-splash the Electron app first shows a dark splash (logo, OpenHands Agent Canvas, spinner, Starting backend services…, then live service-log lines, a first-launch note and Show details). Read recipe ↓
  • F26.desktop-main-window after boot a native window with a File/Edit/View/Window menu loads http://localhost:8000 with the key injected (first run, no API-key screen). Read recipe ↓
  • F26.desktop-macos-titlebar on macOS the desktop window hides the native title bar and reserves a 28 px drag band (titlebar-drag-region, aria-hidden) above the shell, so the sidebar logo clears the traffic lights and the window can be dragged; the band is dropped in native fullscreen, also after a reload while fullscreen. Linux and Windows windows and browser tabs render no band (window.desktopShell is undefined in a tab; platform is linux on Linux). The band is rendered by the root layout, its error shell and the config-loading spinner, not by the first-run onboarding screen. Blocked without macOS (#17474).

Readable recipes

Read each script from top to bottom. Code is copied from the map; prose gives the action, expected observation, and conditions. <id>, <run> and similar placeholders stand for values from your own run. Short forms such as browser count continue the same control-openhands invocation; they are kept as documented.

Expected observations describe the recipe’s contract. Captures below selected recipes show representative real states from this snapshot; they do not mark every mapped behavior as passed. Follow cleanup before moving to another family.

Version #

  1. Do
    node bin/agent-canvas.mjs --version; echo "exit=$?"
  2. Do
    node bin/agent-canvas.mjs -v; echo "exit=$?"
  3. Note
    Each prints one line equal to jq -r .version package.json (the checkout's package version, which changes with every release: compare, do not expect a number; e.g. [ "$(node bin/agent-canvas.mjs --version)" = "$(jq -r .version package.json)" ] && echo same) and exit=0.

Info #

  1. Do
    node bin/agent-canvas.mjs --info
  2. Expect
    It prints @openhands/agent-canvas <version> (the --version value), Default stack versions: with agent-server: <pin> and automation: <pin>, Compatibility: agent-server: >= <minimum>, Default ports: ingress: 8000, agent-server: 18000, automation: 18001, and the override variables OH_AGENT_SERVER_VERSION, OH_AGENT_SERVER_GIT_REF, OH_AGENT_SERVER_LOCAL_PATH / OH_AUTOMATION_VERSION, OH_AUTOMATION_GIT_REF.
  3. Expect
    The three versions are read from config/defaults.json (versions.agentServer, versions.automation, compatibility.minimumAgentServer) and move with every pin bump, so compare them with the file instead of expecting numbers: test "$(node bin/agent-canvas.mjs --info | awk '/^(Default stack versions|Compatibility):/{s=1;next} /^$/{s=0} s{print $NF}' | paste -sd' ' -)" = "$(jq -r '[.versions.agentServer,.versions.automation,.compatibility.minimumAgentServer]|join(" ")' config/defaults.json)" && echo match || echo mismatch prints match, and printf '%s\n' "$(jq -r .compatibility.minimumAgentServer config/defaults.json)" "$(jq -r .versions.agentServer config/defaults.json)" | sort -VC && echo pin-meets-minimum || echo below-minimum prints pin-meets-minimum (the default Agent Server passes the UI's own version gate).

Help #

  1. Do
    node bin/agent-canvas.mjs --help
  2. Expect
    It prints USAGE:, AUTH MODES: (--public ... Users must paste it when the UI loads.), OPTIONS: (-p, --port, -H, --host, --public, --frontend-only, --backend-only, -v, --version, --info, -h, --help), ENVIRONMENT VARIABLES: and EXAMPLES:, exit 0.

Conflicting flags #

  1. Do
    node bin/agent-canvas.mjs --frontend-only --backend-only; echo "exit=$?"
  2. Note
    → Error: --frontend-only and --backend-only cannot be used together, exit=1.
  3. Do
    node bin/agent-canvas.mjs --public --frontend-only; echo "exit=$?"
  4. Note
    → Error: --public cannot be used with --frontend-only, exit=1.

Public mode needs a key #

  1. Note
    Run T=$(mktemp -d); env -u LOCAL_BACKEND_API_KEY HOME=$T OH_CANVAS_SAFE_STATE_DIR=$T/state timeout 60 node bin/agent-canvas.mjs --public --port 18990; echo "exit=$?"; rm -rf $T.
  2. Expect
    After ✓ uvx found it prints ✗ PUBLIC MODE requires LOCAL_BACKEND_API_KEY environment variable. and exit=1. (The successful --public launch is control-openhands launch --new --public, mapped in F01.)

Missing build #

  1. Note
    Never move this checkout's build/ (other runs serve it); copy the launcher instead: T=$(mktemp -d); mkdir -p $T/bin $T/config; cp bin/agent-canvas.mjs $T/bin/; cp package.json $T/; cp config/defaults.json $T/config/; node $T/bin/agent-canvas.mjs --port 18990; echo "exit=$?"; rm -rf $T.
  2. Expect
    It prints Error: No build found at <T>/build, the
  3. Do
    npm install
  4. Note
    /
  5. Do
    npm run build
  6. Note
    hint and exit=1.

Port in use #

  1. Note
    Point a second launcher at your run's ingress port: P=$(control-openhands status | jq -r .ports.ingress); T=$(mktemp -d); HOME=$T OH_CANVAS_SAFE_STATE_DIR=$T/state timeout 60 node bin/agent-canvas.mjs --port $P; echo "exit=$?"; rm -rf $T.
  2. Expect
    It prints Cannot start: the following ports are already in use: with • ingress: port <P> and Another agent-canvas instance may already be running., then exits 1.
  3. Note
    Your run is untouched (control-openhands doctor stays ok).

Loopback-only ingress #

  1. Note
    Your run was started without --host.
  2. Note
    Run P=$(control-openhands status | jq -r .ports.ingress); curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:$P/ (200), then find the machine's own address portably, LAN=$(node -e 'for (const a of Object.values(require("os").networkInterfaces()).flat()) if (a.family === "IPv4" && !a.internal) { console.log(a.address); break }'); echo "LAN=$LAN", and
  3. Do
    curl -s -o /dev/null -w '%{http_code}\n' --connect-timeout 3 http://$LAN:$P/; echo "exit=$?"
  4. Note
    At that address curl prints 000 and exit=7 (connection refused).
  5. Note
    If LAN is empty the machine has no non-loopback address: record the loopback half and leave the refused-connection half not-run with that reason (it is the behavior this ID asserts, so an empty LAN is never a pass).
  6. Note
    Read-only second view, Linux form: awk -v p=$(printf '%04X' $P) 'NR>1 && $4=="0A" && $2 ~ ":"p"$" {print $2}' /proc/net/tcp prints only 0100007F:<hex port> (127.0.0.1 in /proc/net/tcp's byte order; elsewhere list the listener with the OS's socket tool, for example ss -ltn 'sport = :'$P or lsof -nP -iTCP:$P -sTCP:LISTEN, and expect 127.0.0.1:<P> only), and grep -a 'Listening on' $OH_VERIFY_RUN/private/stack.log shows the ingress banner Listening on: http://localhost:<P>/.
  7. Expect
    The LAN half of F26.host-bind-lan below is the contrast: there the same curl answers 200.

Backend-only #

  1. Note
    Start it: Q=$OH_VERIFY_RUN/private/f26-backend; mkdir -p $Q; HOME=$Q/home OH_CANVAS_SAFE_STATE_DIR=$Q/state OH_CANVAS_SAFE_BACKEND_PORT=18961 OH_CANVAS_SAFE_AUTOMATION_PORT=18962 OH_CANVAS_SAFE_VITE_PORT=18963 LOCAL_BACKEND_API_KEY=qa-f26-backend-key OH_SECRET_KEY=qa-f26-secret DO_NOT_TRACK=1 nohup node bin/agent-canvas.mjs --backend-only --port 18960 > $Q/launcher.log 2>&1 & echo $! > $Q/pid.
  2. Note
    Wait until
  3. Do
    curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18960/health

    is 200 (30–90 s; the first start downloads the agent-server into the private HOME).

  4. Do
    curl -s http://127.0.0.1:18960/
  5. Note
    prints No backend configured for this route (status 503, also for /index.html and /assets/x.js), /server_info is 200, /api/settings is 401 without a key and 200 with -H 'X-Session-API-Key: qa-f26-backend-key'.
  6. Note
    Keep it running for the next two bullets.

Frontend-only #

  1. Note
    Start it: Q=$OH_VERIFY_RUN/private/f26-frontend; mkdir -p $Q; HOME=$Q/home OH_CANVAS_SAFE_STATE_DIR=$Q/state OH_CANVAS_SAFE_BACKEND_PORT=18971 OH_CANVAS_SAFE_AUTOMATION_PORT=18972 OH_CANVAS_SAFE_VITE_PORT=18973 DO_NOT_TRACK=1 nohup node bin/agent-canvas.mjs --frontend-only --port 18970 > $Q/launcher.log 2>&1 & echo $! > $Q/pid (ready in a few seconds). for u in / /server_info /api/settings /sockets/events/x /api/automation/v1 /vscode/; do curl -s -o /dev/null -w "%{http_code} $u\n" http://127.0.0.1:18970$u; done prints 200 / and 503 for the four backend paths and for /vscode/: the launcher serves the VS Code editor and reserves its path, and grep -a 'vscode -> 503' $Q/launcher.log prints [static] /vscode -> 503 (rejected) (see Gotchas).
  2. Note
    In the browser:
  3. Do
    control-openhands browser goto http://127.0.0.1:18970/ --allow-external
  4. Check
    control-openhands browser value 'testid=onboarding-backend-name'

    (Local),

  5. Check
    control-openhands browser value 'testid=onboarding-backend-host'

    (http://127.0.0.1:18970, the page origin), sleep 12,

  6. Check
    control-openhands browser toasts --history

    ([]: no error toast) and

  7. Do
    control-openhands browser screenshot --feature F26.frontend-only --name add-backend-step

    (the Add a backend card).

Cross-connect #

  1. Note
    On that page run
  2. Do
    control-openhands browser fill 'testid=onboarding-backend-name' 'QA Backend Only'
  3. Do
    control-openhands browser fill 'testid=onboarding-backend-host' 'http://127.0.0.1:18960'
  4. Do
    control-openhands browser fill 'testid=onboarding-backend-api-key' qa-f26-backend-key
  5. Do
    control-openhands browser click 'testid=onboarding-backend-next'
  6. Wait
    control-openhands browser wait 'testid=onboarding-step-choose-agent' --timeout 20000
  7. Note
    On a backend state that has never answered telemetry consent, the consent dialog opens on top (control-openhands browser wait 'testid=telemetry-consent-form' --timeout 10000 succeeds; it appears a second or two after the step, so an immediate browser count can still read 0; consent is stored on the backend, so a second pass with the same $Q skips it):
  8. Do
    control-openhands browser uncheck 'testid=telemetry-consent-form >> role=checkbox'
  9. Do
    control-openhands browser click 'testid=confirm-telemetry-preferences'
  10. Do
    control-openhands browser click 'testid=onboarding-skip'
  11. Wait
    control-openhands browser wait 'testid=root-layout' --timeout 20000
  12. Note
    After
  13. Do
    control-openhands browser reload
  14. Check
    control-openhands browser snapshot 'testid=backend-selector'

    shows status "Connected" and combobox "QA Backend Only".

  15. Do
    control-openhands browser goto http://127.0.0.1:18970/settings/secrets --allow-external
  16. Check
    control-openhands browser count 'testid=secret-item >> has-text=OPENHANDS_AUTOMATION_API_KEY'

    is 1 (the backend-only instance's seeded secret), and

  17. Check
    control-openhands browser network --filter 18960 --last 5

    shows byOrigin with only http://127.0.0.1:18960.

  18. Check
    control-openhands browser errors --app-only

    has pageErrors 0.

Remote backend from a normal Canvas #

  1. Note
    Needs the backend-only instance.
  2. Do
    control-openhands browser goto /
  3. Do
    control-openhands browser click 'testid=backend-selector'
  4. Do
    control-openhands browser click 'testid=add-backend-menu-item'
  5. Do
    control-openhands browser click 'testid=add-backend-option-agent-server'
  6. Do
    control-openhands browser click 'testid=add-backend-location-option-remote'
  7. Check
    control-openhands browser text 'testid=add-backend-agent-server-panel'
  8. Note
    includes Run the remote backend with --public, set a strong LOCAL_BACKEND_API_KEY, and expose it through an SSH tunnel, ngrok, or a TLS reverse proxy. Fill testid=add-backend-name with QA_Remote, testid=add-backend-host with http://127.0.0.1:18960 and testid=add-backend-api-key with qa-f26-backend-key, click testid=add-backend-submit, then
  9. Wait
    control-openhands browser wait 'testid=add-backend-modal' --state detached --timeout 15000
  10. Do
    control-openhands browser reload
  11. Check
    control-openhands browser snapshot 'testid=backend-selector'
  12. Note
    : status "Connected", combobox "QA_Remote" (the new backend becomes active).
  13. Note
    If you run this bullet without Cross-connect first, the backend-only state has not answered telemetry consent yet: the consent dialog (This preference is saved for the local backend “QA_Remote” at http://127.0.0.1:18960.) opens over the page and blocks the selector; answer it as in Cross-connect (control-openhands browser wait 'testid=telemetry-consent-form' --timeout 10000, control-openhands browser uncheck 'testid=telemetry-consent-form >> role=checkbox', control-openhands browser click 'testid=confirm-telemetry-preferences').
  14. Note
    Switch back with
  15. Do
    control-openhands browser click 'testid=backend-selector'
  16. Do
    control-openhands browser click 'role=option[name="Local"]'
  17. Wait
    control-openhands browser wait 'testid=backend-selector >> role=combobox[name="Local"]' --timeout 5000

    (the switch applies after a short delay: a snapshot taken right after the click still reads combobox "QA_Remote": Local; after the wait it shows combobox "Local").

  18. Note
    Clean up: backend-selector → testid=manage-backends-menu-item;
  19. Check
    control-openhands browser text 'testid=manage-backends-row-QA_Remote'

    reads QA_Remote, v<version>, http://127.0.0.1:18960, Connected, LOCAL, where <version> is the backend-only instance's own report,

  20. Do
    curl -s http://127.0.0.1:18960/server_info | jq -r .version

    (the versions.agentServer pin in config/defaults.json unless your shell exports an OH_AGENT_SERVER_* override, which a hand-started launcher inherits); it passes the UI's gate: printf '%s\n' "$(jq -r .compatibility.minimumAgentServer config/defaults.json)" "$(curl -s http://127.0.0.1:18960/server_info | jq -r .version)" | sort -VC && echo meets-minimum || echo below-minimum prints meets-minimum.

  21. Note
    Then click testid=manage-backends-remove-QA_Remote, testid=confirmation-modal >> testid=confirm-button, testid=manage-backends-done, browser reload.

Seeded Local backend #

  1. Note
    On your run,
  2. Do
    control-openhands browser eval "JSON.parse(localStorage.getItem('openhands-backends')).map(b=>({id:b.id,name:b.name,host:b.host,kind:b.kind,hasKey:!!b.apiKey}))"

    returns exactly one entry {id: "default-local", name: "Local", host: "http://127.0.0.1:<ingress>", kind: "local", hasKey: true} (after the Remote cleanup above), and

  3. Do
    control-openhands browser eval "({key: Boolean(window.__AGENT_CANVAS_SESSION_API_KEY__), authRequired: window.__AGENT_CANVAS_AUTH_REQUIRED__ === true})"

    is {key: true, authRequired: false}.

Rotated session key #

  1. Note
    Still on your run's /,
  2. Do
    control-openhands browser eval "JSON.parse(localStorage.getItem('openhands-backends'))[0].apiKey === window.__AGENT_CANVAS_SESSION_API_KEY__"

    is true (compare keys inside the page; never print one).

  3. Note
    Run md5sum $OH_VERIFY_RUN/private/session-key | cut -c1-12, then
  4. Do
    control-openhands restart --rotate-key

    (rotatedKey true: the launcher comes back injecting a new key) and the md5sum line again; the hash changed.

  5. Expect
    The open page still holds the old key, so its polls get 401 until it reloads: run
  6. Check
    control-openhands browser errors --clear
  7. Do
    control-openhands browser reload
  8. Wait
    control-openhands browser wait 'testid=home-screen' --timeout 20000
  9. Check
    control-openhands browser count

    is 1 for testid=root-layout and 0 for testid=api-key-entry-screen, testid=agent-server-onboarding-screen and testid=onboarding-modal: Home, not the key prompt public mode shows in the same state (F01.api-key-entry).

  10. Note
    Second view:
  11. Do
    control-openhands browser eval "JSON.parse(localStorage.getItem('openhands-backends')).map(b=>({id:b.id,host:b.host,keyMatchesPage:b.apiKey===window.__AGENT_CANVAS_SESSION_API_KEY__}))"

    is [{id: "default-local", host: "http://127.0.0.1:<ingress>", keyMatchesPage: true}] against the new page key,

  12. Do
    control-openhands browser eval "JSON.parse(localStorage.getItem('openhands-agent-server-config')).sessionApiKey === window.__AGENT_CANVAS_SESSION_API_KEY__"

    is true (the legacy key is overwritten too),

  13. Check
    control-openhands browser network --filter 'api/settings' --last 2

    shows status 200 under recent, and

  14. Check
    control-openhands browser errors --app-only

    has pageErrors 0 and appErrors 0.

  15. Check
    control-openhands api GET /api/settings
  16. Note
    works with the rotated key, and
  17. Do
    control-openhands browser screenshot --feature F26.session-key-rotated --name home-after-rotation

    shows Home.

Stop the partial stacks #

  1. Note
    kill -TERM $(cat $OH_VERIFY_RUN/private/f26-frontend/pid) $(cat $OH_VERIFY_RUN/private/f26-backend/pid); after about 5 s the ports 18960–18963 and 18970–18973 are free again (check as in Preconditions).
  2. Note
    Kill by the saved pid only, never by pattern.

LAN bind #

  1. Note
    Start a full stack on all interfaces: Q=$OH_VERIFY_RUN/private/f26-lan; mkdir -p $Q; HOME=$Q/home OH_CANVAS_SAFE_STATE_DIR=$Q/state OH_CANVAS_SAFE_BACKEND_PORT=18961 OH_CANVAS_SAFE_AUTOMATION_PORT=18962 OH_CANVAS_SAFE_VITE_PORT=18963 LOCAL_BACKEND_API_KEY=qa-f26-lan-key OH_SECRET_KEY=qa-f26-secret DO_NOT_TRACK=1 nohup node bin/agent-canvas.mjs --host 0.0.0.0 --port 18960 > $Q/launcher.log 2>&1 & echo $! > $Q/pid, and wait until
  2. Do
    curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18960/

    is 200. grep -a 'not loopback' $Q/launcher.log shows [auth] Bind host 0.0.0.0 is not loopback — session key will not be injected into HTML and [static] WARNING: bind host 0.0.0.0 is not loopback; refusing to inject the session API key into HTML. The served page proves it:

  3. Do
    curl -s http://127.0.0.1:18960/ | grep -o '__AGENT_CANVAS_\(SESSION_API_KEY\|AUTH_REQUIRED\)__' | sort -u
  4. Note
    prints only __AGENT_CANVAS_AUTH_REQUIRED__ and
  5. Do
    curl -s http://127.0.0.1:18960/ | grep -c qa-f26-lan-key
  6. Note
    prints 0, while your loopback run's page,
  7. Check
    curl -s "$(control-openhands status | jq -r .baseUrl)/" | grep -o '__AGENT_CANVAS_\(SESSION_API_KEY\|AUTH_REQUIRED\)__' | sort -u
  8. Note
    prints only __AGENT_CANVAS_SESSION_API_KEY__.
  9. Expect
    The bind is real: with LAN from the loopback bullet,
  10. Do
    curl -s -o /dev/null -w '%{http_code}\n' --connect-timeout 3 http://$LAN:18960/

    is 200 here (refused on your run, F26.loopback-bind-default; with an empty LAN this check is not-run).

  11. Expect
    The browser part below expects a profile that has never visited 127.0.0.1:18960 (true in this file's order; on a repeat pass run control-openhands browser reset first and restore your run afterwards, see Gotchas).
  12. Do
    control-openhands browser goto http://127.0.0.1:18960/ --allow-external
  13. Do
    control-openhands browser eval "({key: Boolean(window.__AGENT_CANVAS_SESSION_API_KEY__), authRequired: window.__AGENT_CANVAS_AUTH_REQUIRED__ === true})"
  14. Note
    → {key: false, authRequired: true}.
  15. Expect
    The onboarding shows testid=onboarding-step-check-backend with
  16. Check
    control-openhands browser enabled 'testid=onboarding-backend-next'
  17. Note
    false (no key typed).
  18. Do
    control-openhands browser click 'testid=onboarding-skip'
  19. Wait
    control-openhands browser wait 'testid=api-key-entry-screen' --timeout 15000
  20. Note
    succeed; after sleep 12,
  21. Check
    control-openhands browser toasts --history

    is [] (no error toast on either screen);

  22. Do
    control-openhands browser screenshot --feature F26.host-bind-lan --name api-key-screen
  23. Note
    Fill testid=api-key-entry-name with QA_LAN and testid=api-key-entry-api-key with qa-f26-lan-key, click testid=api-key-entry-submit;
  24. Wait
    control-openhands browser wait 'testid=root-layout' --timeout 10000
  25. Note
    succeeds.
  26. Note
    Stop it with kill -TERM $(cat $Q/pid).

LAN opt-in #

  1. Note
    Expected: the opt-in the warning names (Pass --host 127.0.0.1 (default) for local mode, or --allow-lan-session-key only if you accept LAN exposure.) makes the npm launcher inject the key again.
  2. Note
    Start the LAN stack as above with --host 0.0.0.0 --allow-lan-session-key --port 18960 (log to $Q/launcher2.log), wait for 200, then
  3. Do
    curl -s http://127.0.0.1:18960/ | grep -o '__AGENT_CANVAS_\(SESSION_API_KEY\|AUTH_REQUIRED\)__' | sort -u
  4. Note
    Today it prints only __AGENT_CANVAS_AUTH_REQUIRED__ and the log repeats both "not loopback" warnings: the launcher ignores the flag (fail; see Gotchas).
  5. Note
    Stop it with kill -TERM $(cat $Q/pid).

Generated session key persists #

  1. Note
    With 18960–18963 free, define a helper and start a loopback stack without a key: Q=$OH_VERIFY_RUN/private/f26-keys; mkdir -p $Q; start(){ HOME=$Q/home OH_CANVAS_SAFE_STATE_DIR=$Q/state OH_CANVAS_SAFE_BACKEND_PORT=18961 OH_CANVAS_SAFE_AUTOMATION_PORT=18962 OH_CANVAS_SAFE_VITE_PORT=18963 OH_SECRET_KEY=qa-f26-secret DO_NOT_TRACK=1 "$@" > $Q/launcher.log 2>&1 & echo $! > $Q/pid; for i in $(seq 1 60); do c=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18960/); [ "$c" = 200 ] && break; sleep 3; done; echo "code=$c"; }; start env -u LOCAL_BACKEND_API_KEY nohup node bin/agent-canvas.mjs --port 18960 (code=200).
  2. Do
    curl -s http://127.0.0.1:18960/ | grep -o '__AGENT_CANVAS_SESSION_API_KEY__[^;<]*' | md5sum
  3. Note
    prints a hash (never print the key itself); $Q/home/.openhands/agent-canvas/api-key.txt exists (64 hex characters) and
  4. Do
    curl -s -o /dev/null -w '%{http_code}' -H "X-Session-API-Key: $(cat $Q/home/.openhands/agent-canvas/api-key.txt)" http://127.0.0.1:18960/api/settings

    is 200. kill -TERM $(cat $Q/pid); sleep 6, run the same start ... line again in the same shell and repeat the md5sum line: the hash is identical.

Pinned session key #

  1. Note
    Same shell, after kill -TERM $(cat $Q/pid); sleep 6: start env LOCAL_BACKEND_API_KEY=qa-f26-pin-key nohup node bin/agent-canvas.mjs --port 18960.
  2. Do
    curl -s http://127.0.0.1:18960/ | grep -o '__AGENT_CANVAS_SESSION_API_KEY__[^;<]*'
  3. Note
    prints __AGENT_CANVAS_SESSION_API_KEY__="qa-f26-pin-key"; /api/settings is 200 with -H 'X-Session-API-Key: qa-f26-pin-key' and 401 with the saved generated key.
  4. Note
    In the browser,
  5. Do
    control-openhands browser goto http://127.0.0.1:18960/ --allow-external
  6. Do
    control-openhands browser eval "({key: window.__AGENT_CANVAS_SESSION_API_KEY__ === 'qa-f26-pin-key', authRequired: window.__AGENT_CANVAS_AUTH_REQUIRED__ === true})"
  7. Note
    → {key: true, authRequired: false}.

Bind host from the environment #

  1. Note
    Same shell, after kill -TERM $(cat $Q/pid); sleep 6: start env LOCAL_BACKEND_API_KEY=qa-f26-pin-key OH_BIND_HOST=0.0.0.0 nohup node bin/agent-canvas.mjs --port 18960. grep -a 'not loopback' $Q/launcher.log shows the same [auth] and [static] warnings as --host 0.0.0.0, and
  2. Do
    curl -s http://127.0.0.1:18960/ | grep -o '__AGENT_CANVAS_\(SESSION_API_KEY\|AUTH_REQUIRED\)__' | sort -u
  3. Note
    prints only __AGENT_CANVAS_AUTH_REQUIRED__.
  4. Note
    Stop it with kill -TERM $(cat $Q/pid); after about 6 s ports 18960–18963 are free.

Frontend-only, returning user #

  1. Note
    Same shell, after kill -TERM $(cat $Q/pid); sleep 6.
  2. Note
    Arrange a browser that onboarded on this origin against the full launcher: start env LOCAL_BACKEND_API_KEY=qa-f26-pin-key nohup node bin/agent-canvas.mjs --port 18960 (code=200),
  3. Do
    control-openhands browser reset

    (a fresh profile, which also forgets your run's onboarding: restore it at the end),

  4. Do
    control-openhands browser goto http://127.0.0.1:18960/ --allow-external
  5. Wait
    control-openhands browser wait 'testid=telemetry-consent-form' --timeout 15000

    (this backend state has never answered consent; a second pass on the same $Q skips the form),

  6. Do
    control-openhands browser uncheck 'testid=telemetry-consent-form >> role=checkbox'
  7. Do
    control-openhands browser click 'testid=confirm-telemetry-preferences'
  8. Wait
    control-openhands browser wait 'testid=onboarding-step-choose-agent' --timeout 15000
  9. Do
    control-openhands browser click 'testid=onboarding-skip'
  10. Wait
    control-openhands browser wait 'testid=root-layout' --timeout 20000
  11. Do
    control-openhands browser eval "JSON.parse(localStorage.getItem('openhands-backends')).map(b=>({id:b.id,name:b.name,host:b.host}))"

    is [{id: "default-local", name: "Local", host: "http://127.0.0.1:18960"}].

  12. Note
    Now serve the same port without backends: kill -TERM $(cat $Q/pid); sleep 6; start nohup node bin/agent-canvas.mjs --frontend-only --port 18960 (code=200, and curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18960/server_info is 503).
  13. Do
    control-openhands browser goto http://127.0.0.1:18960/ --allow-external
  14. Wait
    control-openhands browser wait 'testid=agent-server-onboarding-screen' --timeout 20000
  15. Note
    succeed: the gate of F25.recovery-gate, not onboarding.
  16. Check
    control-openhands browser count

    is 1 for testid=manage-backends-modal and 0 for testid=onboarding-step-check-backend, testid=first-run-onboarding-screen, testid=api-key-entry-screen, testid=root-layout, testid=close-manage-backends-modal and testid=manage-backends-done;

  17. Do
    control-openhands browser press Escape
  18. Note
    leaves the modal (count 1).
  19. Check
    control-openhands browser text 'testid=manage-backends-row-Local'

    reads Local, http://127.0.0.1:18960, Disconnected, HTTP request failed (503 Service Unavailable): "Service Unavailable (no backend configured for this route)" and LOCAL, and

  20. Check
    control-openhands browser testids

    lists manage-backends-add (Add Backend) as the way on.

  21. Do
    control-openhands browser screenshot --feature F26.frontend-only-returning --name recovery-gate
  22. Expect
    The 503 probes are expected errors in the sweep:
  23. Check
    control-openhands browser errors

    (without --app-only, which keeps only your run's origin) lists external:http-error rows with 503 for http://127.0.0.1:18960/server_info, /api/settings and /api/llm/models/verified, and pageErrors is 0.

  24. Note
    Stop it with kill -TERM $(cat $Q/pid); after about 6 s ports 18960–18963 are free.
  25. Note
    Restore your run:
  26. Do
    control-openhands browser goto /
  27. Do
    control-openhands onboard --skip

Runtime services #

  1. Check
    control-openhands api GET /server_info --pick runtime_services.mode

    (agent-canvas) and

  2. Check
    control-openhands api GET /server_info --pick runtime_services.services.automation.url_from_agent

    (http://localhost:<automation port>; control-openhands status prints the ports).

  3. Wait
    control-openhands conversation start --prompt "Reply with the single word: ok" --wait --timeout 180

    (prints <id>) and

  4. Check
    control-openhands conversation events <id> --grep RUNTIME_SERVICES --from-start
  5. Note
    : the SystemPromptEvent matches, with an excerpt <RUNTIME_SERVICES>\nYou are running inside an agent-canvas dev stack started ....
  6. Check
    control-openhands conversation events <id> --grep "localhost:<automation port>" --from-start

    shows * Automation backend: http://localhost:<port> plus its Docs: and OpenAPI: URLs.

OpenHands Canvas home running on a healthy local CLI-launched stack.
The CLI launches a real local Canvas with Agent Server and automation services. CLI capture · 1440 × 1000 · 9 October 2026 · Canvas 8793c111

A real local CLI-launched Canvas is rendered; runtime_services.mode reports agent-canvas.

Only normal local runtime shown. This screenshot is not Docker, Electron, LAN, partial-stack or embeddable-library proof.

How this screenshot was taken

agent server: 1.53.0 · automation: 1.19.0 (launcher default) · canvas: 1.26.0

control-openhands launch --new --print-run
control-openhands doctor
control-openhands onboard --skip
control-openhands api GET /server_info --pick runtime_services.mode
control-openhands browser goto /
control-openhands browser screenshot --feature F26.runtime-services --name local-runtime

The agent uses the runtime services #

  1. Note
    Write qa-f26-agent.txt (outside the workspace) containing: Using the Automation backend described in your RUNTIME_SERVICES (read its OpenAPI to find the routes), create one automation named QA_rt_auto from a prompt: the prompt is "Reply with the single word: pong. Do not run any tools.", the schedule is the cron 0 9 * * *, and it is enabled. Then dispatch one run of it now. Do not change anything else. Reply with the automation id and the run id only. Run
  2. Wait
    control-openhands conversation start --prompt "$(cat qa-f26-agent.txt)" --wait --timeout 400

    (note <agent-id>); the reply names <automation-id> and a run id.

  3. Check
    control-openhands conversation events <agent-id> --kinds ActionEvent --from-start

    shows the agent's own curl calls: http://localhost:<automation port>/api/automation/openapi.json, POST …/api/automation/v1/preset/prompt and POST …/api/automation/v1/<automation-id>/dispatch, each with the X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY header the block names.

  4. Note
    Second view in the UI:
  5. Do
    control-openhands browser goto /automations
  6. Check
    control-openhands browser text '[data-testid^=automation-card-] >> has-text=QA_rt_auto'

    (QA_rt_auto, the prompt, cron, Runs 1).

  7. Note
    Open it with
  8. Do
    control-openhands browser click '[data-testid^=automation-card-] >> has-text=QA_rt_auto >> text=QA_rt_auto' --expect-url '/automations/[0-9a-f-]+'
  9. Note
    Within about a minute,
  10. Check
    control-openhands browser text 'testid=automation-activity-log'

    shows the run's summary (Replied with the single word "pong" …), a cost and Successful (control-openhands browser screenshot --feature F26.runtime-services-agent-use --name agent-created-automation-run).

  11. Check
    control-openhands api GET /api/automation/v1/<automation-id>

    has name QA_rt_auto, trigger.schedule 0 9 * * * and enabled true.

  12. Note
    Delete it afterwards (control-openhands api DELETE /api/automation/v1/<automation-id> --write, arrange).
  13. Expect
    It runs daily at 09:00 UTC otherwise.

Library build #

  1. Note
    Never build inside the shared checkout (it regenerates i18n and typegen files the other runs hash); build in a copy: L=$OH_VERIFY_RUN/private/f26-lib; mkdir -p $L; tar --exclude=./node_modules --exclude=./build --exclude=./.git --exclude=./dist -cf - . | tar -xf - -C $L; ln -s "$PWD/node_modules" $L/node_modules; (cd $L && npm run build:lib); echo "exit=$?".
  2. Expect
    It ends with ✓ built in and exit=0 (about 30 s).
  3. Note
    Then (cd $L && node -e 'const p=require("./package.json"),fs=require("fs");let ok=0,miss=[];for(const[k,v]of Object.entries(p.exports))for(const f of(typeof v==="string"?[v]:Object.values(v)))fs.existsSync(f)?ok++:miss.push(k+" -> "+f);console.log(JSON.stringify({entries:Object.keys(p.exports),ok,missing:miss}))') prints 9 entries (., ./browser, ./conversation, ./files, ./settings, ./sidebar, ./terminal, ./i18n, ./package.json), ok 25 and missing []. grep -ohE 'AgentServerUIProviders|AgentServerUIRoot|CloudOrganizationBoundary|configureTelemetry|setTelemetryConsent' $L/dist/lib/index.d.ts | sort -u lists all five.
  4. Note
    Remove the copy with rm -rf $L.

Style scope #

  1. Note
    On your run's home page (control-openhands browser goto /) run
  2. Do
    control-openhands browser eval "(() => { let total=0, unscoped=[]; for (const sh of document.styleSheets) { let rules; try { rules = sh.cssRules } catch { continue } const walk = (rs) => { for (const r of rs) { if (r.selectorText) { total++; if (!r.selectorText.includes('data-agent-server-ui')) unscoped.push(r.selectorText) } else if (r.cssRules) walk(r.cssRules) } }; walk(rules) } return { scopeRoots: document.querySelectorAll('[data-agent-server-ui]').length, total, unscopedCount: unscoped.length, sample: unscoped.slice(0, 12) } })()"
  3. Note
    total is in the thousands and unscopedCount is 2: only .go<hash> and .go<hash> > *, react-hot-toast's runtime classes.
  4. Do
    control-openhands browser eval "getComputedStyle(document.querySelector('[data-agent-server-ui]')).getPropertyValue('--oh-color-base').trim()"

    is #181818.

  5. Expect
    This proves the bundle is scoped, not that a host page is unaffected (F26.lib-host-app).

Blocked #

  1. Note
    If
  2. Do
    docker info
  3. Note
    cannot reach the daemon but dockerd is installed, start it (setsid dockerd); without one, record both Docker rows blocked naming the missing daemon.
  4. Note
    With a daemon, run the image with -p 127.0.0.1:8000:8000, open http://localhost:8000/canvas and expect the Add a backend step (testid=onboarding-step-check-backend, Next disabled until a key is typed); onboarding-skip leads to testid=api-key-entry-screen.
  5. Note
    With -e AGENT_CANVAS_ALLOW_LAN_SESSION_KEY=true the key is injected and first run opens on Choose your agent, under the telemetry consent dialog.
  6. Do
    npm run build:docker
  7. Note
    behind a TLS-intercepting proxy needs the proxy CA inside the build stages, and Docker Hub may answer 429.
  8. Note
    Record F26.lib-host-app blocked naming the missing host-app example. command -v helm kubectl prints nothing here: record F26.helm-chart blocked naming the missing helm binary and cluster; with them, helm lint helm/agent-canvas and helm template qa helm/agent-canvas render the StatefulSet, Service and Ingress, and helm install follows helm/agent-canvas/README.md.

Desktop splash and window #

  1. Note
    Start a private display and the app in one process group: Q=$OH_VERIFY_RUN/private/f26-desktop; mkdir -p $Q/shots; setsid nohup sh -c "echo \$\$ > $Q/pgid; Xvfb :78 -screen 0 1440x1000x24 -nolisten tcp & sleep 1; DISPLAY=:78 HOME=$Q/home OH_CANVAS_SAFE_STATE_DIR=$Q/state LOCAL_BACKEND_API_KEY=qa-f26-desktop-key OH_SECRET_KEY=qa-f26-secret DO_NOT_TRACK=1 exec node_modules/.bin/electron --no-sandbox electron" > $Q/desktop.log 2>&1 &, then capture frames: for i in $(seq 1 40); do sleep 1; DISPLAY=:78 import -window root $Q/shots/s$(printf %02d $i).png; done.
  2. Note
    Read the frames: the first seconds show the splash (logo, OpenHands Agent Canvas, AI coding agent interface, spinner, Starting backend services…, then lines such as agent-server: {"asctime": ..., the note First launch downloads Python + the OpenHands agent server. and a Show details button); a later frame shows the main window with a File Edit View Window menu and the first-run Choose your agent step under the consent dialog naming the local backend "Local" at http://localhost:8000 (about 30 s on a warm cache, minutes on the first download).
  3. Note
    Copy the frames you cite into $OH_VERIFY_RUN/evidence/F26.desktop-boot-splash/ and .../F26.desktop-main-window/.
  4. Note
    Stop in two steps: kill -TERM $(cat $Q/pgid) signals only the Electron launcher (it quits and stops its backend services); after about 10 s ports 8000, 18000, 18001 and 3001 are free again.
  5. Note
    Then kill -TERM -- -$(cat $Q/pgid) ends Xvfb; ps -o pid,cmd -g $(cat $Q/pgid) lists nothing.

External links #

  1. Note
    Not driven: record not-run (harness gap: no verb clicks inside the Electron window or observes the OS browser handoff).

Gotchas and known limits

  • control-openhands launch covers only the full stack and --public. Partial stacks and --host need a hand-started launcher with its own HOME, OH_CANVAS_SAFE_STATE_DIR and all three OH_CANVAS_SAFE_*_PORT variables; without them it writes into the real ~/.openhands/agent-canvas and grabs the default ports 8000/18000/18001/3001 that another agent may be using.
  • A hand-started launcher's first start downloads the agent-server into its private HOME cache (30–90 s). Prefix assignments are expanded left to right, so HOME=$Q/home UV_CACHE_DIR=$HOME/.cache/uv points the cache into $Q/home too, not at your real cache. Reuse one $Q for repeat launches to keep it warm.
  • The browser daemon refuses other origins unless you pass --allow-external to browser goto. Each port is its own origin, so a partial-stack page starts at first run again (onboarding and a consent dialog), independent of your run's onboarding.
  • In a frontend-only UI the Add a backend step's Next is enabled with an empty key, while on a LAN or --public stack it is disabled until a key is typed: the host the step probes differs (503 vs. auth-required).
  • A partial-stack origin keeps its stored backend in the browser profile across launches, so a second pass lands straight in the shell. control-openhands browser reset brings back the first run on every origin; then control-openhands browser goto / and control-openhands onboard --skip restore your own run's baseline.
  • Which screen a frontend-only origin shows depends on the browser, not the launcher: a profile new to the origin gets Add a backend (first run owns the initial backend), while a profile that stores a backend for that origin (the default-local entry a full launcher on the same port seeded, or any backend added there earlier) gets the recovery gate of F25.recovery-gate as soon as that backend answers 503 (F26.frontend-only-returning). The no-toast check belongs to the first-run state only: on the gate, browser toasts --history lists generic An error occurred status toasts raised by the failing 503 probes (two within 12 s here), with no hint of which request failed. Known failure (reproduced 2026-10-08): both toasts come from the free-models hydrator's GET /api/llm/models/verified 503, which no meta.disableToast guards on that screen (nor does backend-version.tsx); #18160.
  • Stopping a backend-only instance while the frontend-only page that uses it stays open is the same backend-down state as F25.recovery-gate after a reload; keep the returning-user bullet above as the frontend-only-specific check (it reproduces the default-local case).
  • The launcher serves the bundled VS Code editor. #17660 made that opt-in (OH_CANVAS_ENABLE_VSCODE=true) and #18048 reverted it, so only a checkout between the two needs the variable (launch --vscode sets it). A launcher that serves it reserves /vscode/: a frontend-only stack answers 503 there and logs [static] /vscode -> 503 (rejected); a stack with an agent-server lists /vscode → http://127.0.0.1:<agent-server port + 1000> in its ingress banner and hands the agent-server that editor port (19961 for the hand-started launchers here, 19000 for the desktop app), which the launcher's own port check does not cover. On a checkout between the two without the variable, /vscode/ is an ordinary SPA URL (200). Here the agent-server has no editor binary (VSCode server binary not found, VSCode will be disabled in the launcher log, /api/vscode/status {"running":false,"enabled":true}), so the editor port stays free even then.
  • The port-in-use error is printed twice, the second time with a Node stack trace, and suggests PORT=<other> rather than --port (#17949). The --public key error's example says npm run dev -- --public instead of the npx command.
  • A backend-only instance's /server_info.runtime_services still describes the ingress as routing /* to the frontend, which it does not run (#17949).
  • The Electron app hardcodes ports 8000/18000/18001 (and the static server takes 3001), ignores PORT, and refuses to start as root without --no-sandbox (Running as root without --no-sandbox is not supported). npm run desktop first runs build:app, which rebuilds build/ under every other run: start node_modules/.bin/electron --no-sandbox electron directly against the existing build.
  • Never stop the desktop app with one group-wide signal: when Xvfb dies with Electron, the app exits without stopping its backend, which is left orphaned (own process groups, parent 1) on 8000/18000/18001/3001 and blocks the next start. If that happens, find the orphans by their ports' command lines (ps -eo pid,pgid,cmd, look for --port 18000, --port 18001, --port 3001, ingress.mjs --port 8000) and stop each group by id.
  • The generated session key lives in $HOME/.openhands/agent-canvas/api-key.txt, not under OH_CANVAS_SAFE_STATE_DIR: a hand-started launcher without a private HOME reads and writes the operator's real key file.
  • A browser origin keeps the backends it stored on an earlier pass: after the LAN pass on 18960, a later loopback launch on 18960 shows the stale QA_LAN backend (red, old key) instead of onboarding, although the page injects a valid key. Assert key-injection bullets through the window globals, or browser reset first.
  • Electron's splash shows each raw service-log line (agent-server: {"asctime": ...) as its status text during boot; the named phases only appear between them.
  • build:lib from a copy whose node_modules is a symlink emits bundled dependencies under dist/<absolute node_modules path>/...; that path is an artifact of the copy, not of the product.
  • **Bug candidate (F26.host-bind-lan-optin):** scripts/bind-host.mjs tells npm users to pass --allow-lan-session-key, but neither bin/agent-canvas.mjs nor scripts/dev-with-automation.mjs parses or forwards it (only scripts/static-server.mjs and the Docker entrypoint do), and unknown flags are silently ignored (#17949, whose first item is exactly this; reproduced again on 2026-10-08 at d2c89252d).
  • Known issue #17946: the npm launcher never creates its TMUX_TMPDIR, so hand-started instances share one tmux server and a restart of one resets the others' terminals.

Source paths: bin/agent-canvas.mjs, scripts/dev-with-automation.mjs, scripts/dev-safe.mjs, scripts/dev-static.mjs, scripts/dev-extra-backend.mjs, scripts/bind-host.mjs, scripts/static-server.mjs, scripts/ingress.mjs, scripts/runtime-services-info.mjs, config/defaults.json, src/api/agent-server-adapter.ts, src/api/backend-registry/, docker/, electron/, helm/agent-canvas/, src/lib/index.ts, docs/SELF_HOSTING.md.