LLMs Technical Reviews

OpenHands/OpenHands

Agent Canvas: a React control plane that configures and streams OpenHands or ACP agents running in a separate Python Agent Server.

GitHub ↗★ 90kTypeScriptMITcommit b0a1a2d · 2026-10-06homepage ↗

Overview

At this commit, the OpenHands/OpenHands repository no longer contains the OpenHands agent. It contains Agent Canvas: a React 19 / React Router web app, an npm launcher, an Electron shell and a Docker image. Together they start, watch and steer coding-agent conversations that run inside a separate Python Agent Server. The repo’s own contributor notes say this directly. Agents, tools, conversations, events and the REST/WebSocket contract belong to OpenHands/software-agent-sdk. This repo owns “Agent Canvas UI, frontend state, backend selection, frontend service integration, and local-stack orchestration”.

So if you want to read how the agent decides, edits or sandboxes, the code is not here. What is here is the control plane. It decides which agent runs (the native OpenHands CodeActAgent through LiteLLM, or an external ACP agent such as Claude Code, Codex or Gemini CLI), with which model, skills, MCP servers, hooks, confirmation policy and security analyzer, in which workspace (local folder, git worktree or Docker), and on which backend (local Agent Server or OpenHands Cloud). It also renders the event stream and handles approvals, condensation, planning and child conversations.

It is aimed at developers who want one self-hosted dashboard for several agents and backends, plus scheduled or webhook-triggered automations. It is not a terminal agent.

Architecture

flowchart LR
  USER["Browser / Electron"] --> UI["Canvas React app"]
  UI --> SVC["ConversationService"]
  SVC --> AD["agent-server-adapter"]
  AD --> TSC["@openhands/typescript-client"]
  TSC -->|"REST"| AS["Agent Server (Python SDK)"]
  AS -->|"WebSocket frames"| WS["ConversationWebSocketProvider"]
  WS --> STORE["Zustand event store"]
  STORE --> UI
  AS --> OH["OpenHands agent via LiteLLM"]
  AS --> ACP["ACP subprocess: Claude Code / Codex / Gemini"]
  AS --> WSP["Local, worktree or Docker workspace"]
  LAUNCH["agent-canvas launcher"] --> AS
  LAUNCH --> AUTO["Automation backend"]
  LAUNCH --> ING["Ingress proxy + static build"]
Component Path Role
Launcher bin/agent-canvas.mjs, scripts/dev-*.mjs Starts Agent Server and the automation backend through uvx, plus the static frontend behind an ingress proxy
Version pins config/defaults.json Agent Server and automation versions, minimum compatible server, ports, paths
Conversation builder src/api/agent-server-adapter.ts Turns saved settings into the StartConversationRequest (agent kind, tools, skills, policy, workspace)
Conversation service src/api/conversation-service/ Create, send, condense, fork, switch profile/model; local vs cloud paths
Event stream src/contexts/conversation-websocket-context.tsx Routes /sockets/session/{id} frames into the event store, with streaming deltas and resume cursor
Wire types src/types/agent-server/ TypeScript mirrors of SDK actions, observations and events
Client tools src/api/canvas-ui-client-tool.ts, launch-child-conversation-client-tool.ts Tools the agent calls that Canvas itself fulfils
Backends src/api/backend-registry/ Local and cloud backend records, health and auth
Desktop / Docker electron/, docker/ Desktop packaging; an image layered on the Agent Server image

How a request flows

  1. Stack startup. npx @openhands/agent-canvas checks for a frontend build and hands off to dev-with-automation.mjs in static mode (agent-canvas.mjs). The launcher builds the Agent Server command (a pinned PyPI version, a git ref or a local checkout) and spawns it with its own state directory and ports (dev-static.mjs, defaults.json).
  2. Create. A new chat calls the conversation service. On a local backend, it resolves a working directory (by default a new git worktree), then builds the payload with encrypted settings and posts it through ConversationClient.createConversation (agent-server-conversation-service.api.ts).
  3. Pick the agent. buildConfiguredAgentSettings branches on agent_kind. ACP agents get an ACP command (the provider’s default if none is set), a model, and forwarded MCP config. OpenHands agents get a normalised LiteLLM config with stream = true, plus skills and tools (agent-server-adapter.ts).
  4. Wrap the conversation. buildStartConversationRequest adds the workspace (DockerExecutionWorkspace or LocalWorkspace), the confirmation policy, max_iterations (default 500), stuck_detection: true, the two Canvas client tools (OpenHands agents only), hooks, secrets as LookupSecret references, and the security analyzer (agent-server-adapter.ts, L1112-L1127).
  5. Run. The Agent Server runs the agent loop: LLM call, action, observation, repeat. None of that code is in this repository.
  6. Stream back. Canvas subscribes to /sockets/session/{id}. routeSessionFrame sends delta frames to a batcher for token streaming, opens and aborts streaming slots, advances the resume cursor on durable frames, and shows error frames (conversation-websocket-context.tsx).
  7. Approve. When the server reports waiting_for_confirmation, the confirmation buttons call EventService.respondToConfirmation (event-service.api.ts).

Key components

The conversation adapter

agent-server-adapter.ts (about 1,800 lines) is the most important file in the repo. It is where Canvas decides what an agent is. Confirmation is a small mapping. confirmation_mode off gives NeverConfirm. On with the LLM analyzer, it gives ConfirmRisky at threshold HIGH, with unknown risk also confirmed. Otherwise it gives AlwaysConfirm. The analyzer itself is LLMSecurityAnalyzer, PatternSecurityAnalyzer or PolicyRailSecurityAnalyzer (agent-server-adapter.ts). The shipped defaults are confirmation_mode: false with the LLM analyzer, a 240-event condenser, and openai/gpt-5.6-sol as the model (settings.ts). Out of the box, nothing asks before running.

Local planner

“Create a Plan” starts a second, hidden conversation that is linked to the parent. Its raw agent spec has only glob, grep and a planning file editor scoped to PLAN.md, its own system prompt, an LLMSummarizingCondenser (max_size 100, keep_first 6) and NeverConfirm (agent-server-adapter.ts). This is a clean way to keep planning read-only. The tool list itself enforces it, not a prompt.

Client tools

Canvas registers tools that the agent calls but the browser fulfils. canvas_ui_control switches the right-hand panel to a file, preview or tab, and its description tells the model when to call it (canvas-ui-client-tool.ts). launch_child_conversation lets the agent start an independent child conversation locally or on OpenHands Cloud.

Wire types

src/types/agent-server/ mirrors the SDK’s event model. Editing goes through FileEditorAction with view, create, str_replace, insert and undo_edit (action.ts). Subagents use TaskAction with a subagent_type and optional resume (action.ts). Context compaction arrives as a CondensationEvent listing forgotten event ids and a summary (condensation-event.ts). These types are the best map of what the server-side agent can do, but they are copies, and the server is the authority.

Extending it

  • Skills: public skills are bundled from @openhands/extensions. User and project skills are loaded by the server, and per-skill enable/disable lists live in settings.
  • MCP: stdio, SSE and HTTP servers in mcp_config. For ACP agents they are forwarded to the subprocess.
  • Hooks: .openhands/hooks.json in the workspace root, loaded through the Agent Server’s hooks endpoint (hooks-service.ts).
  • ACP agents: any agent with an ACP command line can be a backend agent, chosen in settings.
  • Canvas extensions: manifest-described pages rendered in iframes, installed from git or local paths.
  • Agent behaviour itself (new tools, a different loop, a runtime) is extended in the Python SDK, not here.

Running it

  • npx @openhands/agent-canvas needs Node.js 24 or newer and uv. It starts Agent Server, the automation backend and the UI, binds to localhost, and generates an API key that it injects into the page. --public requires LOCAL_BACKEND_API_KEY and does not inject the key.
  • The Docker image is built on the Agent Server image and serves everything on one port. A desktop build is available through Electron.
  • LLM keys and models are set in the web UI, not through environment variables.

Strengths and caveats

  • Strength: agent-agnostic front end. The same UI, approvals, skills, MCP config and automations work for the native OpenHands agent and for ACP agents such as Claude Code or Codex.
  • Strength: honest version contract. defaults.json pins the server version and a minimum compatible server, and contributor rules require raising it when Canvas depends on new server behaviour.
  • Strength: per-conversation isolation by default. On a local backend, new conversations get their own git worktree unless you attach a folder.
  • Caveat: the agent is not here. The loop, tools, sandbox, condenser and security analyzers are all in the Python SDK. A review of this repo is a review of the control plane.
  • Caveat: permissive defaults. Confirmation is off by default, and the local workspace runs commands directly on the host. Docker isolation depends on how the server was started.
  • Caveat: large, fast-moving adapter. Much of the logic is version negotiation with the SDK (“older servers ignore the field”) in one big adapter file. Expect churn.

Sources: code at b0a1a2d, verified Q&A.

How it answers the Open-source coding agents questions

Each answer was drafted by a code-reading agent at commit b0a1a2d. Its citations were checked mechanically. Compare with the other open-source coding agents →

How is the agent loop implemented?

answered

Agent Canvas is a frontend that delegates the agent loop to a backend Agent Server (Python, software-agent-sdk). Canvas builds the start-conversation payload in src/api/agent-server-adapter.ts (lines 1072–1091), which selects between two agent kinds: OpenHands (direct LLM via LiteLLM) or ACP (external CLI subprocess like Claude Code/Codex/Gemini). The payload includes max_iterations (default 500, line 147), stuck_detection: true, a confirmation_policy, and a security_analyzer. A second local planner agent is spawned via buildStartPlanningConversationRequest (lines 1450–1572) with a PlanningFileEditorAction tool operating on PLAN.md; it uses its own LLMSummarizingCondenser (max_size: 100, keep_first: 6, lines 1520–1525). Once the conversation is created, the frontend communicates over a WebSocket (conversation-websocket-context.tsx, line 1–86) that streams events: user messages, actions (tool calls), observations (tool results), and conversation state updates. The turn structure flows as: user sends a message → ConversationClient.sendEvent (api line 464–468) → server processes → events stream back over WebSocket → the frontend renders them. The action type system (src/types/agent-server/core/base/action.ts, lines 6–370) defines ~20 tool types including ExecuteBashAction, FileEditorAction, GlobAction, GrepAction, BrowserNavigateAction, TaskAction (for sub-agents), SwitchLLMAction, and MCPToolAction. Sub-agents are launched via TaskAction with fields prompt, subagent_type, description, and resume (lines 282–299); their results arrive as TaskObservation with task_id, subagent, and status (observation.ts lines 305–326). Stop conditions are expressed through ExecutionStatus (common.ts lines 67–75): idle, running, paused, waiting_for_confirmation, finished, error, stuck.

How is repository context gathered and kept within the context window?

answered

Context management has three main mechanisms. 1. Skills (knowledge context): Bundled public skills from @openhands/extensions are loaded into agent_context.skills (agent-server-adapter.ts lines 828–853) alongside user- and project-authored skills. Skills are filtered via allow/deny lists (buildSkillEnablementFilter, line 878) and invoked by keyword triggers or explicit name. 2. Search/grep tools: The agent has GlobAction (pattern + path) and GrepAction (regex + path + glob filter) at its disposal (action.ts lines 249–273), plus FileEditorAction with a view command that reads file ranges (view_range, lines 89–94). There is no separate embeddings-based retrieval or repo-map generation in the Canvas frontend — the agent-server SDK handles those if configured. 3. Context-window compaction: Two compaction strategies exist. The LLMSummarizingCondenser is a server-side rolling summarizer configured on the planning agent (agent-server-adapter.ts lines 1520–1525: max_size: 100, keep_first: 6) and on OpenHands agents via settings (condenser: { enabled: true, max_size: 240 }, services/settings.ts lines 43–46). It fires automatically as the conversation grows, emitting CondensationEvent objects (condensation-event.ts lines 5–27: forgotten_event_ids and summary). The manual compact action (use-compact-context-action.ts lines 26–119) lets the user request compaction on demand via useCondenseConversation; it captures a baseline token count, posts a condense request, and awaits the resulting CondensationEvent with fewer tokens. The context-window state is displayed via useContextWindowUsage which reads per_turn_token and context_window from the metrics store. Token usage is tracked per usage-id (usage_to_metrics in conversation-state-event.ts lines 47–53), including caching stats (cache_read_tokens, cache_write_tokens).

How are code edits applied?

answered

Code editing is done through the FileEditorAction tool (action.ts lines 65–95). It supports five commands: view, create, str_replace, insert, and undo_edit. The str_replace command takes old_str and new_str (exact string replacement — not a diff/patch format). The insert command inserts new_str after line insert_line. The create command takes file_text for the full file body. There is also a StrReplaceEditorAction (lines 96–125) with the same command set as a separate tool name, and a PlanningFileEditorAction (lines 218–247) used exclusively by the local planner to edit PLAN.md. Each edit returns a FileEditorObservation (observation.ts lines 111–146) carrying command, output, path, prev_exist (whether the file existed before), old_content, new_content, and error. The undo_edit command reverts the last edit to a single file — the old_content/new_content fields let the agent see the delta. Edits are validated server-side by the agent-server's file editor implementation (not by Canvas's frontend): there is no separate lint-run or test-run step in the tool chain visible in this codebase. Git integration is available through the workspace: the frontend provides a diff view via the Files tab (referenced in the Canvas UI tool description at canvas-ui-client-tool.ts lines 39–40: "The Files tab automatically renders a diff view when the workspace has uncommitted git changes"), and there are git_service endpoints on the agent-server, but there is no client-side git-commit-after-edit hook in the Canvas UI code.

How are shell commands and file writes kept safe?

answered

Safety is enforced through layered mechanisms. 1. Confirmation policies are set per conversation at creation time (agent-server-adapter.ts lines 730–755): NeverConfirm (no prompts), ConfirmRisky (threshold HIGH + confirm unknown), or AlwaysConfirm. The policy is sent in the start-conversation payload as confirmation_policy (line 1358). 2. Security analyzers (line 744–755) can be LLMSecurityAnalyzer (an LLM judges command risk), PatternSecurityAnalyzer (pattern/rule-based), or PolicyRailSecurityAnalyzer. The default is security_analyzer: "llm" (services/settings.ts line 14) and confirmation_mode: false (line 13). The settings page lives at verification-settings.tsx (lines 1–44). 3. Execution runtime modes are reported by the agent-server as execution_runtime: "local" | "docker" (agent-server-compatibility.ts line 38). Docker mode uses DockerExecutionWorkspace with working dir /workspace (adapter.ts lines 517–522), providing container-level isolation. Local mode uses LocalWorkspace with the configured working_dir. 4. Secrets are encrypted (Fernet tokens with prefix gAAAAA, adapter.ts line 643) and decrypted server-side. They flow as LookupSecret objects (lines 1144–1149) rather than plaintext env vars — the agent-server resolves them from its own store. 5. Network restrictions: Docker mode sandboxes the agent inside a container, naturally restricting network access. Checkpoint/sandbox lifecycle (pause, resume, missing) is managed via the cloud SandboxService (cloud/sandbox-service.api.ts). There is no separate Landlock/seatbelt mechanism visible in Canvas — that is an agent-server concern.

Editor's note. Correction: nothing in this repo restricts network access for Docker execution; Canvas only picks DockerExecutionWorkspace vs LocalWorkspace, and with the shipped defaults (confirmation_mode: false) the policy is NeverConfirm, so a local-workspace agent runs commands on the host without asking.

Which models are supported and how are they called?

answered

Two agent kinds determine model support. OpenHands agents use LiteLLM, which makes any LLM accessible via its provider prefix (e.g. openai/gpt-5.6-sol, the default at services/settings.ts line 6). The model config includes model, api_key, base_url, and supports subscriptions (ChatGPT via LLMSubscriptionService, subscription-service.ts lines 1–30, with a device-OAuth flow). ACP agents delegate to external CLI subprocesses: Claude Code (claude-code), Codex (codex), Gemini CLI (gemini-cli), or a custom command. These are defined in acp-providers.ts (lines 1–80) which reads from @openhands/typescript-client's ACP registry. ACP models use acp_model, acp_command, acp_server fields (adapter.ts lines 959–1027). Model routing is done through saved LLM profiles (named model configs) and meta-profiles (classifier-based routers). The SwitchLLMAction (action.ts lines 301–310) allows mid-conversation model switching via profile_name + reason, returning a SwitchLLMObservation with active_model. The ClassifyAndSwitchLLMObservation (observation.ts lines 364–387) supports the Model Router meta-profile feature: a classifier selects from saved LLM profiles. The route_task_to_model tool is attached by the agent-server when a meta-profile is active, gated by run_router_at_conversation_start (adapter.ts lines 364–379). Cost tracking is handled by MetricsSnapshot (conversation-state-event.ts lines 25–41) with per-usage-id costs, token counts, response latencies, and prompt/completion/cache breakdowns. An LLMBalanceService (llm-balance-service.ts lines 1–50) provides credit-balance queries for OpenRouter. The default model is openai/gpt-5.6-sol (settings.ts line 6). Support for stream: true is forced for both OpenHands and ACP agents (adapter.ts lines 1041, 1470).

How can it be extended and customised?

answered

Extensibility is built at multiple levels. 1. MCP (Model Context Protocol): The mcp_config setting supports stdio, SSE, and HTTP servers, including OAuth2 device-auth flows. The MCPService (mcp-service.api.ts, lines 1–100) proxies test/ping/health operations for both local and cloud backends. MCP health monitoring runs via probe-mcp-server-health.ts. 2. Canvas Extensions (the "Apps" system): Plugins defined by a canvas-extension.json manifest can contribute custom pages (CanvasExtensionPageContribution in canvas-extension.ts lines 4–11). Extensions are installed from git sources or local paths through CanvasExtensionsService (canvas-extensions-service.ts lines 1–60), which has an app-backend bridge capability. They render in iframes with validated session tokens (canvas-extension-app-view.ts lines 32–59). 3. Skills: Public skills are bundled at build time from @openhands/extensions (agent-server-adapter.ts lines 828–853, using the SKILLS_CATALOG). User and project skills are loaded from disk by the agent-server (load_user_skills: true, load_project_skills: true, line 909–910). Skills use the AGENTS.md format with keyword triggers, descriptions, and compatibility constraints. 4. Hooks system: Workspace hooks are loaded from .openhands/hooks.json by HooksService (hooks-service.ts lines 1–50). Hooks fire on stop, pre_tool_use, post_tool_use events (types lines 264–271), with command or prompt executors and configurable timeouts. 5. Client tools: The ClientToolSpec interface (canvas-ui-client-tool.ts lines 9–20) allows registering custom tools with JSON Schema parameters and annotations (readOnlyHint, destructiveHint, etc.). Two built-in client tools are canvas_ui_control (UI navigation) and launch_child_conversation (spawn sub-conversations). 6. SDK: The @openhands/typescript-client npm package provides typed API clients for headless use. The ACP Protocol lets any external agent (Claude Code, Codex, Gemini, custom ACP) be used as a backend.