Replace cloud AI coding assistants with opencode, a provider-agnostic Node CLI plus Electron desktop app. Points at any OpenAI-compatible endpoint, ships in three frontends. Includes the 2026-05-13 correction on the auto-title-generator Mistral BadRequest gotcha and the JSON-config-only setup syntax.

opencode Setup: Self-Hosted AI Coding Assistant on ARM64

Update (2026-06-19). The “Qwen 3.6 PrismaQuant” references here predate the 2026-06-11 production switch to AutoRound int4-mixed (69.2 tok/s, 12.7 percent better on the coding gate, vision retained, PrismaQuant retired). The figures are kept as the engineering-log record; the live stack is on /stack/ and the switch is measured in AutoRound int4 vs PrismaQuant.

Update 2026-05-18: the migration completed, and the stack changed. This article was written while Mistral Small 4 (SGLang) was the live model and Qwen3.6 was a planned switch. That switch is done. Qwen3.6-35B-A3B-PrismaQuant on vLLM is the production model now. opencode runs against it with no strict-alternation BadRequest (Qwen’s chat template does not enforce role alternation, so the auto-title-generator double-USER passes cleanly), plus the sovereign-ai MCP server wired in. Validated numbers from a direct single-shot coding bench (12 exercism python tasks, temp 0, real pytest): Qwen3.6 4/12 full-pass vs Mistral-Small-4 1/12. The speed went 45 to ~70 tok/s with DFlash speculative decoding. An earlier internal note claiming “Mistral 0/30” was a broken-harness artifact (the Aider/litellm benchmark hangs client-side behind this stack’s Tor docker proxy and never reaches the model); real Mistral single-shot is 8%, not 0. Two follow-up articles cover the desktop-freeze root cause and the measurement trap. Sections below are kept as the original reasoning trail; read this box as the current state.

Correction 2026-05-13: original article claimed opencode is not affected by the Mistral strict-alternation BadRequest bug class. First-run test against local Mistral showed this is wrong: the auto-title generator sends two consecutive USER messages and gets rejected with HTTP 400. Plus the opencode config set CLI does not exist (use the JSON config file). Both sections corrected inline below.

New to self-hosting AI? The Self-Hosted AI: Start Here hub walks the hardware-decision tree, inference-engine choice, and the operational gotchas that bite hardest in the first three months.

Quick Take

  • opencode is a Node-based AI coding assistant with three frontends from one config: CLI, Electron desktop app, and opencode serve web mode.
  • Provider-agnostic: speaks the OpenAI completions API and points at any local endpoint (SGLang on port 30000, vLLM on 30001, anything that answers /v1/chat/completions).
  • Replaces the OpenHands agent layer on this stack, which needed eight published fix articles to stay running on Mistral Small 4 because of structural Microagent-injection bugs.
  • Costs the Docker-sandbox model of OpenHands. opencode runs in your shell, modifies your files directly. Pair with per-shell-command approval prompts and the same git-discipline that keeps human-typed mistakes from being permanent.
  • This article documents the install, the config for a local OpenAI-compatible endpoint, and what changes on day one of running it.

This stack ran OpenHands as the agent layer from April through May. The setup recipe is documented at OpenHands Setup with Mistral-via-SGLang and the BadRequest fix at OpenHands BadRequest Fix. Both articles are still accurate for anyone running OpenHands today, the recipes work, the workarounds hold. What changed is that the bug class kept generating new shapes, and the cost-benefit on this hardware no longer favored keeping OpenHands in the chair.

opencode vs OpenHands at the architecture level

OpenHandsopencode
RuntimeDocker container (sandbox)Node.js CLI plus TUI
Installdocker run --rm ... (multi-GB image, plus runtime sandbox images on first agent action)npm i -g opencode-ai (~50 MB)
Sandboxfull Docker sandbox with its own shell, file system, network namespaceruns in YOUR shell, modifies YOUR files directly
FrontendsWeb UI only (single browser tab against the container)CLI plus Desktop App (Electron) plus Web Server mode
ProviderOpenAI-compatible plus Anthropic; per-provider plumbingOpenAI-compatible (any provider that speaks the API)
Bad-Request-classShips the Mistral strict-alternation bug structurally (#14287)Narrower variant via auto-title generator (see correction below)

The flexibility row matters most in practice. opencode runs in three modes from one ~/.opencode/ config. Start a refactor in the desktop app on the couch, drop into the CLI from a terminal to verify a build, open opencode serve to share the session with a second machine. The state is shared, not the process. OpenHands is single-mode by design.

The sandbox row is the cost worth being honest about. OpenHands’ Docker-sandbox model meant a runaway agent could not rm -rf your home directory because the agent did not live there. opencode runs in your actual shell. An over-eager tool call can do real damage to real files. The mitigation is the per-shell-command approval prompt, an explicit allowlist, and the discipline you already apply to keep human-typed mistakes from being permanent (commit early, branch always, never --force without thinking).

Install opencode

Two install paths, both produce the same CLI binary plus a launcher that opens the desktop app:

# Path A: npm global, fast, ARM64-supported
npm i -g opencode-ai@latest

# Path B: Homebrew tap (macOS or Linux with brew)
brew install anomalyco/tap/opencode

# Optional: Electron desktop app via Homebrew cask
brew install --cask opencode-desktop

After install, verify:

opencode --version
opencode --help

The desktop app reads the same ~/.opencode/config.json that the CLI writes. Install the cask only if you actually want the GUI. The CLI alone is enough for terminal-first workflows.

Point opencode at your local inference endpoint

opencode does not ship with a provider preset for self-hosted SGLang or vLLM, but the openai-compatible provider handles them. Config is in ~/.opencode/config.json:

{
  "provider": "openai-compatible",
  "api_base": "http://localhost:30000/v1",
  "api_key": "not-needed-local",
  "model": "Mistral-Small-4"
}

Correction 2026-05-13: the opencode config set ... CLI does not exist in 1.14.48. The real config path is a JSON file edit. Full example with both local providers in one file:

{
  "$schema": "https://opencode.ai/config.json",
  "provider": {
    "local-sglang": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Local SGLang Mistral",
      "options": {
        "baseURL": "http://localhost:30000/v1",
        "apiKey": "not-needed-local"
      },
      "models": {
        "Mistral-Small-4": {"name": "Mistral Small 4 (local SGLang)"}
      }
    },
    "local-qwen": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Local Qwen3.6 vLLM",
      "options": {
        "baseURL": "http://localhost:30001/v1",
        "apiKey": "not-needed-local"
      },
      "models": {
        "qwen3.6-35b": {"name": "Qwen3.6-35B-A3B (local vLLM)"}
      }
    }
  }
}

The apiKey field is a placeholder. SGLang on a private network does not require authentication, but the OpenAI client library refuses to send a request without a non-empty key. not-needed-local is conventional.

The model name under each provider’s models block must match the --served-model-name the inference server published. Select the active provider/model at run time with opencode run --model local-sglang/Mistral-Small-4 "..." (or pick from the TUI provider switcher).

Context-length gotcha (Mistral safer-config users)

opencode reserves max_tokens=32000 for completion by default on the build agent. With an 11552-token system prompt plus the 32000-token reserve, total request size is 43552 tokens, which exceeds the Mistral safer-launch context of 32768. Two options:

A per-agent max_tokens override in opencode.json is theoretically a third option but not validated in this stack yet.

Correction 2026-05-13: opencode has a narrower variant of the BadRequest bug

First run against the local Mistral-Small-4 endpoint produced HTTP 400 from the auto-title-generator. opencode sends two consecutive user messages to the model on every new session:

{"role": "user", "content": "Generate a title for this conversation:\n"},
{"role": "user", "content": "<the actual user prompt>"}

Mistral 400 response:

After the optional system message, conversation roles must alternate user
and assistant roles except for tool calls and results.

Same bug class as the OpenHands RecallAction injection, narrower scope: only the title generator does it, not every turn. Workarounds:

The original sales pitch claim “opencode does not inject synthetic USER messages” was based on the architecture overview, not on a first-run test. The test caught what the overview missed.

Correction 2026-07-06: opencode blocks image input despite attachment: true config

Qwen3.6-35B-A3B on vLLM (:30001) is vision-capable — the AutoRound weights carry a full vision tower, and the --language-model-only flag was removed from the prod launcher on 2026-06-16. Verified live: sending a base64-encoded image directly to the vLLM API returns a correct description.

The problem: opencode blocks images at the frontend with “this model does not support image input” even when attachment: true is set on the model in opencode.json. The image is never sent to the provider — dropped client-side before any API call.

Config tried (no effect):

{
  "provider": {
    "local-qwen": {
      "npm": "@ai-sdk/openai-compatible",
      "models": {
        "qwen3.6-35b": {
          "name": "Qwen3.6-vLLM",
          "attachment": true
        }
      }
    }
  },
  "attachment": {
    "image": {
      "auto_resize": true,
      "max_width": 1024,
      "max_height": 1024,
      "max_base64_bytes": 4194304
    }
  }
}

Direct vLLM API call works:

python3 - <<'PY'
import json, urllib.request
req = {
  "model": "qwen3.6-35b",
  "max_tokens": 60, "temperature": 0,
  "messages": [{
    "role": "user",
    "content": [
      {"type": "text", "text": "Beschreib das Bild."},
      {"type": "image_url", "image_url": {"url": "<image_url>"}}
    ]
  }]
}
r = urllib.request.urlopen(urllib.request.Request(
  "http://localhost:30001/v1/chat/completions",
  data=json.dumps(req).encode(),
  headers={"Content-Type": "application/json"}
), timeout=120)
d = json.load(r)
print(d["choices"][0]["message"]["content"])
PY

Root cause hypothesis: The frontend checks model vision capability against the provider’s /v1/models metadata or a hardcoded list, and attachment: true in opencode.json is not consulted. The check happens before the request is built.

Workaround: Call the vLLM API directly with base64 images. Functional but breaks the opencode workflow — images cannot be part of the conversation context.

Upstream status: Filed as anomalyco/opencode #33542 (assigned to @jlongster). Environment: OpenCode 1.17.13, Go binary aarch64, Qwen3.6-35B-A3B via vLLM, Linux ARM64 (DGX Spark, 121 GB Unified Memory).

Use opencode

Start a session against your current directory:

cd /path/to/your/repo
opencode

This drops into the TUI with a chat panel and a file-tree pane. opencode reads the repo, indexes the file structure, and waits for input. Type a task in natural language:

Refactor the error handling in src/api.ts to use the AppError class instead of raw throw new Error.

opencode plans the edit, shows a diff preview, and prompts before writing files. Approve, deny, or edit the plan inline. The diff is applied to your working tree. No commit, no push, no docker volume copy-out. The change is in your repo as if you had typed it yourself.

For shell commands the agent wants to run (run tests, install a package, check git status), opencode prompts per-command unless the command is on your allowlist. The allowlist is editable in ~/.opencode/allowlist.json. Build it up over time from commands you trust.

Open questions, follow-up article

This article is the install and the why. A day-2 field report follows after running opencode end-to-end against Qwen3.6 for a real coding session, with the open questions:

Target for the field report: end of week.

Rollback to OpenHands

If opencode does not work out, rollback is five minutes of work. The OpenHands container image was removed but is one docker pull ghcr.io/all-hands-ai/openhands:latest away. The state directory archived to /data/openhands-state.archive-2026-05-13/ (patches, sessions, config.toml) is mv back to /data/openhands-state/ and the legacy recreate-openhands.sh is at /data/scripts/archive/recreate-openhands.sh.2026-05-13. The setup recipe at OpenHands Setup with Mistral-via-SGLang is still accurate.

Cross-references

What I Am Trying

  • opencode CLI plus Electron desktop, single config in ~/.opencode/
  • Now pointed at Qwen3.6-35B-A3B-PrismaQuant on vLLM (port 30001), production since 2026-05-18; Mistral SGLang (port 30000) kept as a documented fallback
  • No Mistral-strict-alternation surface against Qwen (its chat template does not enforce alternation), no docker-sandbox layer
  • Git-discipline as the only safety net for in-shell agent actions; explicit allowlist for the common shell calls

Was this worth it? Zap the article.

Value for value, no signup. Sats go straight to the writer.

sats zapped
zaps
Today 7d 30d All-time
Unique readers
Page views
All Article Insights →