Testing OpenHands

ai
development
Author

Werner Schiller

Published

October 20, 2026

Because of the sheer amount of the content, i used AI to help me write this article. Still took me quite some time and effort to rewrite and review it, but i think i need to put that disclaimer here. Some sections sound more AI-ish, and there are words (‘ephemeral’) that i am normally not using actively. I am no native speaker after all and my passive english is way better than my active.

Ending fun with OpenHands

Skip the boring part

Preface

The goal was to install OpenHands to research if there are any practical web based ways to use agents with my backing agentic workflow. It would be really nice to have a centrally configured and managed workflow with centrally assigned models and users can log in and use the workflow and the models to implement tasks - with predefined human interaction gates and live preview of the work done.

Given:

  • a single machine (brainbox) with various docker containers running, including litellm and ollama.
  • An opencode installation (v1.18) and configuration directly on that machine. Configuration according to github.com/gelse/AI-snippets, with the multi-stage agent workflow that i am using successfully.
  • Also litellm as configured multiple models and exposed them to a Virtual Key that i am using in opencode - the opencode configuration is successfully using the variety of models for it’s different agents.

What I tried to do

I deployed OpenHands in Docker — the ghcr.io/openhands/agent-canvas all-in-one container — to run my host opencode from inside it as an ACP agent, reusing the host opencode config and the LAN litellm proxy.

Deployment was successfull, web frontend was exposed and connection was established.

A healthcheck ran fetch("http://localhost:8000/canvas") inside the container every 30 seconds and the container stayed up across recreates (a recreate reported healthy ~11s after start). Two bind mounts: ./state:/home/openhands/.openhands (Canvas + agent-server state, owned by uid 10001) and ./projects:/projects. One secret, LOCAL_BACKEND_API_KEY, injected from .env.

To immediately test the waters for a later ansible deployment, LLM access had been provisioned through the Agent Server API rather than the UI: a provider connection named litellm of provider type litellm_proxy, base_url http://litellm:4000 (an internal network was also provided) with profiles litellm-deepseek-v4-pro (active), litellm-deepseek-v4-flash, and litellm-minimax-m3. One UI quirk worth recording: “the litellm_proxy provider does NOT automatically list remote models - the model name is typed manually as litellm_proxy/”. That quirk foreshadows failure #4.

The integration goal: Agent Canvas, on a local Docker backend, can drive external coding agents through the Agent Client Protocol instead of its built-in agent. The docs’ own definition: “The Agent Client Protocol (ACP) is a standard for talking to coding agents over JSON-RPC on stdio. Instead of Agent Canvas calling an LLM directly, the Agent Server spawns the agent’s own CLI as a subprocess and relays each turn to it.” OpenCode is one of five offered presets. My host opencode was 1.18.35 (i updated since then) with a config directory I actually maintain (see my AI-snippets repo) — authored agents, skills, plugins, MCP servers, and a litellm provider pointed at http://litellm.gelse.lan/v1. The plan:

  1. Mount the host config read-only into the container: /home/werner/.config/opencode:/home/openhands/.config/opencode:ro.
  2. Mount the credential the same way: /home/werner/.local/share/opencode/auth.json:/home/openhands/.local/share/opencode/auth.json:ro.
  3. Leave the Canvas preset command npx -y opencode-ai acp alone and let the agent-server spawn my opencode as an ACP subprocess.

This way the configuration of the host is used, while the opencode instance runs inside the container.

One extra container, my agent, my proxy. That was the whole idea.

What broke, in order

npm 11 EBADPLATFORM on opencode-ai@1.19.0. The agent-server logs showed only “Connection closed” with no stderr; the real failure was npm 11’s strict-platform check rejecting the package’s postinstall on a platform mismatch. What the log actually said, on the very first ACP conversation attempt:

"ACP provider npx cache warm failed: provider=opencode; continuing with normal startup: ACP provider cache warm failed for opencode (exit code 1)"
"Failed to start ACP server: Connection closed"
"ACP model discovery failed: server=opencode code=ACPInitError"

No stderr from the subprocess, no reason, just “Connection closed” — three occurrences in the last 500 log lines on day one alone. The Canvas UI showed a conversation that starts and produces nothing. Running the spawn command by hand inside the container was its own lesson in futility:

$ docker exec openhands bash -c 'cd /tmp && timeout 15 npx -y opencode-ai acp 2>&1 < /dev/null; ec=$?; echo "=== exit=$ec ==="'
=== exit=1 ===

Exit 1, zero output, both as root and as uid 10001. Only npx --loglevel=verbose got a signal out of it: npm info run opencode-ai@1.19.0 postinstall { code: 1, signal: null }, then npm verbose exit 1. A manual repro (npm init -y && npm install opencode-ai in /tmp/oc-test) finally surfaced the real error, which the agent-server had been swallowing whole:

npm error npm error code EBADPLATFORM
npm error npm error notsup Unsupported platform for opencode-linux-x64-musl@1.19.0: wanted {"os":"linux","cpu":"x64","libc":"musl"} (current: {"os":"linux","cpu":"x64","libc":"glibc"})
npm error npm error notsup Valid os:    linux
npm error npm error notsup Actual os:   linux
npm error npm error notsup Valid cpu:   x64
npm error npm error notsup Actual cpu:  x64
npm error npm error notsup Valid libc:  musl
npm error npm error notsup Actual libc: glibc

Twice — the same block for opencode-linux-x64-baseline-musl@1.19.0 — then npm’s own diagnosis:

npm error It seems your package manager failed to install the right opencode CLI package. Try manually installing “opencode-linux-x64” or “opencode-linux-x64-baseline” or “opencode-linux-x64-musl” or “opencode-linux-x64-baseline-musl”.

opencode-ai is a meta package; its postinstall is supposed to place the right native binary, and the platform-specific packages ride along as optional dependencies. The agent-canvas image is glibc; the musl variants it pulled in demanded musl. What I concluded at the time was that npm 11 hard-errors on those optional-dependency platform checks where npm 10 used to skip them — I never verified that comparison externally, so treat it as my working theory, not a measured fact. What is measured: node v24.21.0, npm 11.21.0, egress to registry.npmjs.org fine, exit 1, no binary placed. The direct install (npm install opencode-linux-x64) worked clean — and then hit the next wall, because its package.json carries no bin field at all:

{
  "name": "opencode-linux-x64",
  "version": "1.19.0",
  "preferUnplugged": true,
  "os": ["linux"],
  "cpu": ["x64"]
}

No bin, so no opencode on any PATH — remember that for failure #3.

auth.json bind mount clobbered ownership. Binding the single file reset the parent directory to root:root 0755, which blocked opencode from creating its data subdirs. Running the freshly installed binary directly produced the first honest error message of the whole exercise:

EACCES: permission denied, mkdir '/home/openhands/.local/share/opencode/repos'
    path: "/home/openhands/.local/share/opencode/repos",
 syscall: "mkdir",
   errno: -13,
    code: "EACCES"

Bun v1.3.14 (Linux x64 baseline)

The kernel’s own mountinfo showed exactly what Docker had done with my two read-only mounts:

0:44 /werner/.config/opencode /home/openhands/.config/opencode ro,relatime - btrfs /dev/mapper/ubuntu--vg-lv--0 rw,ssd,discard=async,space_cache=v2,subvolid=5,subvol=/
0:44 /werner/.local/share/opencode/auth.json /home/openhands/.local/share/opencode/auth.json ro,relatime - btrfs /dev/mapper/ubuntu--vg-lv--0 rw,ssd,discard=async,space_cache=v2,subvol=/

Stat on the parent told the rest:

Access: (0755/drwxr-xr-x)  Uid: (    0/    root)   Gid: (    0/    root)
touch: cannot touch '/home/openhands/.local/share/opencode/_test': Permission denied
mkdir: cannot create directory '/home/openhands/.local/share/opencode/_testdir': Permission denied
-rw------- 1 1000 1000 93 Jun  6 21:11 auth.json

Three layers of wrong in one listing: the directory is root-owned because Docker created the bind-mount target path as root; the process runs as uid 10001 (openhands); and even the mounted file itself is mode 600 owned by host uid 1000 — unreadable by the container user regardless. The verbatim error names only repos, because that is the first mkdir that hit the wall; the directory is where opencode keeps all of its state (after the later fixes it held opencode.db, repos/, snapshot/, log/). None of it could be created.

But - i admit - i should have foreseen that one - that’s completely on myself.

opencode missing from PATH. Now the bare opencode acp — and the agent-server’s own npx cache, pinned at opencode-ai@1.18.23, initialized fine:

"Warming ACP provider npx cache: provider=opencode, packages=['opencode-ai@1.18.23']; first use may download the provider before session startup"
"ACP server initialized: agent_name='OpenCode', agent_version='1.18.23'"

Then the first warning of the morning: ACP server offers auth methods ['opencode-login'] but no matching credential is available (OPENCODE_API_KEY is unset) — session creation may fail. I created the secret through the Canvas UI ("Secret created/updated", "secret_name": "OPENCODE_API_KEY"), and the next init fell back cleanly: ACP server offers auth methods ['opencode-login'] that cannot be performed here; using the already-configured credential(s) ['OPENCODE_API_KEY'] instead. The conversation started and immediately died:

"Failed to start ACP server: [Errno 2] No such file or directory: 'opencode'"
FileNotFoundError: [Errno 2] No such file or directory: 'opencode'

with the traceback ending, truthfully, in subprocess.py, line 1991, in _execute_child. The OS could not exec opencode because it does not exist as a bare name anywhere the agent-server looks:

---PATH---
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
---npm config prefix---
/home/openhands/.npm-global

The npm global prefix — where any -g install lands — is not on the PATH the agent-server spawns with. The binary existed at /home/openhands/.npm-global/bin/opencode; the spawn call could not see it.

Stale model id in opencode.json. It carried id: "opencode-go/deepseek-v4-pro" for DeepSeekV4Pro while the litellm proxy exposes plain deepseek-v4-pro; the first real prompt surfaced as “Internal error: Upstream request failed: Invalid credential” with silent retries. With the ACP command switched to the absolute path /home/openhands/.npm-global/bin/opencode acp, the binary spawned and the model list loaded — and the log immediately flagged the version schizophrenia this setup had drifted into:

"ACP provider version mismatch: provider=opencode, pinned_version='1.18.23', reported_version='1.19.0'; provider was probably installed at runtime via the npx fallback rather than preinstalled in the image"

Then the first real prompt, 2026-10-15 06:17:42 UTC (08:17:42 container-local log). The only visible trace was on ACP stderr, relayed line by line into the agent-server log:

Error handling request {
  jsonrpc: "2.0",
  id: 4,
  method: "session/prompt",
  params: {
    sessionId: "ses_ec1ccc0c5ffefDacfDgZoJFNJ8",
    prompt: [
      [Object ...], [Object ...]
    ],
  },
} {
  code: -32603,
  message: "Internal error: Upstream request failed: Invalid credential",
  data: {
    service: "session",
    errorName: "APIError",
  },
}

followed by ACP prompt failed with server error (attempt 2/4), retrying in 15s: [-32603] Internal error: Upstream request failed: Invalid credential. Silent retries, nothing in the UI. The curl proof isolated the cause to a single string — same key, same baseURL, two model ids:

  • model=opencode-go/deepseek-v4-pro → {"error":{"message":"The requested model 'opencode-go/deepseek-v4-pro' is not available for this API key, or the model name is invalid. Check the models available to you and try again.","type":"team_model_access_denied","param":"model","code":"403"}}
  • model=deepseek-v4-pro → "content":"pong"

The key was fine. The endpoint was fine (both litellm.gelse.lan at 10.42.43.13 and in-network litellm at 10.0.14.3 resolved and answered). The stale id in my own config was the entire failure. The fix was a one-line edit to my host config but I never applied it, because by then the verdict was already in. Not because of the configuration problems (which were in big parts my own fault), but because of the intended workflow openhands brings with it (which is not very compatible to mine) and the hiding of errormessages and logs i encountered.

Anatomy of the ACP integration

What Canvas actually spawns

Five presets, straight from the docs, each a subprocess launch line:

Provider Default command
Claude Code npx -y @agentclientprotocol/claude-agent-acp
Codex npx -y @zed-industries/codex-acp
Gemini CLI npx -y @google/gemini-cli --acp
Pi npx -y --package=pi-acp --package=@earendil-works/pi-coding-agent pi-acp
OpenCode npx -y opencode-ai acp

And the detail that determines everything on a containerized backend: “Neither Pi nor OpenCode is pre-installed in the Agent Server image, so the first conversation downloads the CLI through npx.” The agent I chose is not preinstalled in the image. It is fetched at conversation time, into a cache, by a package manager, inside a container - that also means that it needs to be done each time the image updates AND the image itself must have internet access to an additional online dependency, which could, in certain circumstances, pose a security risk.

Of course, i could build the container myself, and if OpenHands would have convinced my with it’s usage and workflow-idea, i definitely would have done that (as i have done with bifrost to install uvx to get stdio MCPs to run). By the way - OmniRoute still does not have MCP proxy functionality, as of 2026-10-19.

Why debugging was blind

The agent-server relays the ACP subprocess’s stderr into its log — but on day one nothing came through. The observable surface was: “Connection closed” (a Python-side ConnectionError: Connection closed raised in acp/client/connection.py, line 150, in initialize, via _start_acp_server), an exit code, and silence, with the real failure output swallowed one layer below where the logs reach. The repeated browser_tool_set tracebacks and a MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 timeout listeners added to [Socket]. MaxListeners is 10. added ambient noise but no signal. Every real root cause — EBADPLATFORM, EACCES, missing PATH entry, stale model id — was found by re-running the failing command by hand inside the container, one layer lower than the logs reach.

The config that almost worked

The mounted opencode.json, as captured inside the container (keys redacted):

{
  "$schema": "https://opencode.ai/config.json",
  "experimental": { "subagent_depth": 4 },
  "permission": { "websearch": "allow" },
  "provider": {
    "litellm": {
      "npm": "@ai-sdk/openai-compatible",
      "options": {
        "baseURL": "http://litellm.gelse.lan/v1",
        "apiKey": "sk-…"
      },
      "models": {
        "DeepSeekV4Pro": {
          "id": "deepseek-v4-pro",
          "name": "DeepSeek v4 Pro",
          "tool_call": true
        },
        "glm-5.3-flash": { "name": "GLM 5.3 Flash", "tool_call": true },
        "minimax-m3": { "name": "Minimax M3", "tool_call": true },
        "mimo-v2.6-pro": { "name": "MiMo v2.6 Pro", "tool_call": true },
        "mimo-v2.6-flash": { "id": "mimo-v2.6-flash", "name": "MiMo v2.6 Flash", "tool_call": true },
        "deepseek-v4-flash": { "name": "DeepSeek V4 Flash", "tool_call": true }
      }
    }
  },
  "mcp": {
    "litellm": {
      "type": "remote",
      "url": "http://litellm.gelse.lan/mcp",
      "enabled": true,
      "headers": { "Authorization": "Bearer sk-…" }
    },
    "ssh-homelab": {
      "type": "remote",
      "url": "http://ssh-mcp.gelse.lan:9080/mcp",
      "enabled": true
    },
    "filesystem": {
      "type": "local",
      "command": ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/home", "/etc", "/srv", "/opt"],
      "enabled": true
    }
  }
}

Everything in this file worked inside the container — config loaded, MCP servers spawned, provider reachable.

Ephemeral by construction

The state volume (./state → /home/openhands/.openhands) holds Canvas conversations, agent profiles (agent-profiles/OpenCode.json), the npm cache the agent-server warms (agent-canvas/conversations/npm-cache/_npx/…), and the opencode data dir. It survives recreates. Everything else — the chown -R 10001:10001, the npm install -g, the symlink in /usr/local/bin — lives in the container layer and evaporates on the next docker compose up -d. That is by design - in my opinion bad design, but by design. The durable fix would have been a small custom image:

# that's only a sketch, it was never built
FROM ghcr.io/openhands/agent-canvas:1.27.0
USER root
RUN npm install -g @opencode/cli && opencode --version
COPY --chown=10001:10001 opencode-config/ /home/openhands/.config/opencode/
USER 10001

Even the “fixed” state was drift-prone: agent-server pinning opencode-ai@1.18.23 in its cache while my absolute-path command ran 1.19.0, both versions live in one process tree, with the log helpfully noting the provider “was probably installed at runtime via the npx fallback rather than preinstalled in the image”.

But that all would not be a problem and is solvable.

Why OpenHands doesn’t fit my workflow

That section is the whole point of the post.

The core mismatch is the agent concept. OpenCode has first-class agents: authored persona objects — system prompt plus model, tools, and permissions — defined in opencode.json or .opencode/agents/*.md, switchable per session and dispatchable as sub-agents. OpenHands’ “agents” are either the built-in OpenHands agent, a generic LLM tool-loop, or external ACP agents (Claude Code, Codex, Gemini CLI, Pi, OpenCode) — CLI subprocesses wrapped in JSON-RPC over stdio, an integration mechanism rather than a customization surface. There is no equivalent of an authored, persona-style agent. If the goal is “use my opencode agents,” OpenHands adds a layer and gives nothing back.

I am currently thinking of a way to mitigate that problem by further leaning towards the skill-rewrite i did a week ago in my agentic configuration. That’s only a concept in my head, and one of the core principles of my setup is that different agent-roles get different models. I think i could solve that problem with dynamic or auto-routing in my litellm instance. That’s something i should look into anyway.

Then there were two LLM configs to keep in sync — Canvas settings and opencode.json, both pointed at the same litellm proxy with the same key, as far as I could tell (Canvas stores its copy as an encrypted global secret and I never diffed the two values, so “same key” is presumption, not proof). Canvas has its litellm_proxy provider that doesn’t list models and makes you type litellm_proxy/<model> by hand; opencode.json has its litellm provider with an explicit model table. Two model catalogs, one proxy, zero synchronization. Redundant. Unless i misunderstood how it is intended to be configured, which absolutely is a possibility.

Some states lived in the container: every fix — npm install, symlink, chown — was ephemeral, one docker compose up -d away from gone. The trial felt like restart after restart before the binary would even spawn due to bad error message forwarding. My memory of four restarts is unverifiable, and the number doesn’t matter: the architecturally true point stands.

All that friction bought a thin wrapper: bind-mount ownership traps, PATH mismatches, npm-version pinning, version drift between the agent-server’s npx cache and the runtime binary, model id drift between host and proxy. The browser-UI-over-opencode benefit never paid for the operational cost.

Even working, every step was a workaround — absolute path in the ACP command, a symlink in /usr/local/bin, a separate litellm_proxy provider in Canvas mirroring the opencode provider. There is no “use my opencode as the agent” button.

What I’m doing instead

I have several ideas - after this i got a bit more into herdr and am currently testing roamgate. Currently this looks very promising, but it is a single-user setup, and i am searching for something that is usable as a centrally hosted enterprise solution.

Written 2026-10-15 when tearing down the deployment. Expanded from the full session transcript (2026-10-14 to 2026-10-15; this expanded edition written 2026-10-16) as source material for a blog post; all quoted log lines, commands, and errors are verbatim from that transcript, with secret values redacted and anything the transcript doesn’t show marked as unknown rather than guessed.