LLMs Technical Reviews

anomalyco/opencode

Client/server TypeScript coding agent on Bun and Effect: any AI SDK provider, permission-ruled tools, TUI, desktop and SDK clients.

GitHub ↗★ 212kTypeScriptMITcommit 4ac0d9c · 2026-10-06homepage ↗

Overview

OpenCode is an open-source coding agent written in TypeScript on Bun. It is built as a client/server system. The agent itself is a local HTTP server, packages/opencode, written with Effect-TS services and storing sessions in SQLite. The terminal UI (SolidJS on OpenTUI), the Electron desktop app, the web app, the IDE integrations and the generated @opencode-ai/sdk are all clients of that server. Even the plain opencode TUI starts the server in a Bun Worker and sends its HTTP requests to it over an in-process RPC bridge (cli/cmd/tui.ts).

The agent is provider-agnostic by design. Models come from the models.dev catalog, providers are loaded through Vercel AI SDK packages, and a set of built-in auth plugins adds logins such as GitHub Copilot, Codex (ChatGPT), GitLab and Cloudflare. The loop is the ordinary single-agent kind: one model, tools, repeat until the model stops. The extras sit around it: named agents with permission rulesets, subagents through a task tool, git-backed snapshots for undo, and a large plugin hook surface.

Architecture

flowchart LR
  TUI["TUI (SolidJS / OpenTUI)"] --> SRV["opencode HTTP server"]
  DESK["Desktop / Web / SDK / ACP"] --> SRV
  SRV --> PROMPT["SessionPrompt.runLoop"]
  PROMPT --> TOOLS["SessionTools.resolve"]
  PROMPT --> PROC["SessionProcessor.process"]
  PROC --> LLM["LLM.stream: AI SDK or native runtime"]
  LLM --> PROV["Provider + models.dev catalog"]
  TOOLS --> REG["ToolRegistry: built-in, custom, plugin"]
  TOOLS --> MCP["MCP clients"]
  TOOLS --> PERM["Permission rulesets"]
  PROC --> SNAP["Snapshot (separate git dir)"]
  PROMPT --> COMP["SessionCompaction"]
  PROMPT --> DB["SQLite (Drizzle)"]
Component Path Role
Server packages/opencode/src/server/ HTTP API (Effect HttpApi), events, mDNS; what every client talks to
Prompt loop packages/opencode/src/session/prompt.ts runLoop: one model step per iteration, subtasks, compaction tasks
Processor packages/opencode/src/session/processor.ts Consumes stream events, records tool parts, doom-loop check, retries
LLM seam packages/opencode/src/session/llm.ts AI SDK streamText by default; opt-in native @opencode-ai/llm runtime
Tools packages/opencode/src/tool/ read, grep, glob, shell, edit, write, apply_patch, task, webfetch, todo, skill, lsp
Agents packages/opencode/src/agent/agent.ts build, plan, general, explore plus user agents, each with a ruleset
Permissions packages/opencode/src/permission/ Wildcard rules with allow / ask / deny
Plugins packages/opencode/src/plugin/, packages/plugin Hook bus, internal auth plugins, npm/local plugins
Front ends packages/tui, packages/desktop, packages/web, packages/sdk Clients of the server API

How a request flows

  1. Enter the loop. SessionPrompt.loop wraps runLoop in SessionRunState.ensureRunning, so one session runs at most one loop (prompt.ts).
  2. Reload and decide. Each iteration reloads the session’s messages from the database, filtering out compacted history. It exits when the last assistant message has a finish reason other than tool-calls or unknown and no pending tool parts (prompt.ts).
  3. Pending tasks first. A queued subtask runs through handleSubtask, and a queued compaction runs compaction.process. If the previous step’s token count overflows the usable window, a compaction task is created and the loop continues (prompt.ts).
  4. Build the step. The loop resolves the agent and its steps limit, applies reminders, and creates the assistant message. SessionTools.resolve then builds the tool set for this agent, model and permission set (prompt.ts). The system prompt is environment + instruction files + MCP instructions + skills (prompt.ts).
  5. Stream. SessionProcessor.process calls llm.stream and runs every event through handleEvent until the stream ends or compaction is needed. A provider-aware retry policy wraps the whole call (processor.ts). By default the AI SDK’s streamText owns provider execution and tool dispatch (llm.ts).
  6. Tool calls. Each wrapped tool fires tool.execute.before, asks the permission service, executes, and fires tool.execute.after (session/tools.ts). On every tool-call event the processor also checks the last three tool parts. If they are identical calls, it raises a doom_loop permission request (processor.ts).
  7. Next step. The processor returns continue, compact or stop. continue loops back to step 2, compact creates a compaction and loops, stop breaks out. Finally, old tool outputs are pruned in the background (prompt.ts).

Key components

Permissions and agents

Permission.evaluate takes the last matching rule across the merged rulesets. When no rule matches, the answer is ask (permission/index.ts). The defaults are permissive, though. Every agent starts from "*": "allow", with ask only for doom_loop, paths outside the project, and .env reads. plan denies edits except plan files. explore denies everything except read, search, web and bash (agent.ts). Out of the box, then, the build agent runs shell commands and edits files without prompting.

Shell tool

shell.ts parses the command with tree-sitter (bash or PowerShell). Every sub-command becomes a permission pattern, with an arity-aware “always” prefix such as git commit *. For file commands (rm, cp, mv and others) it resolves path arguments. Any path outside the project triggers an external_directory request (tool/shell.ts, L385-L415). There is no OS sandbox. Isolation is entirely at the permission layer.

Editing

The registry gives GPT models (except gpt-4 and oss variants) apply_patch and everyone else edit + write (registry.ts). edit is search/replace with a chain of nine fallback matchers, from exact match through line-trimmed, block-anchor (Levenshtein), whitespace-, indentation- and escape-normalised, up to context-aware matching (edit.ts). After writing, tools run the formatter and report LSP diagnostics back to the model.

Compaction

isOverflow compares total tokens with the model’s input limit minus a reserve (by default the smaller of 20k and the max output) (overflow.ts). Compaction has two layers. Pruning erases old tool outputs while keeping the latest 40k tokens’ worth and never pruning skill output. Summarisation then runs a hidden compaction agent (compaction.ts).

Snapshots

Snapshot keeps its own git directory alongside the work tree (--git-dir/--work-tree). It takes a tree at step start and a patch at step end, so session revert does not touch the user’s own git history.

Subagents

task launches a child session with the named agent. Nesting is capped by subagent_depth (default 1). Background subagents sit behind an experimental flag, and child sessions inherit permission denies from the parent (task.ts).

Extending it

  • Custom tools. Any {tool,tools}/*.{js,ts} file in a config directory is imported, and each export becomes a tool (registry.ts).
  • Plugins. Plugins are npm or local modules with hooks such as tool.execute.before/after, shell.env, experimental.chat.messages.transform and experimental.session.compacting, plus auth providers. The built-in ones are listed in internalPlugins (plugin/index.ts).
  • MCP. Servers connect over stdio, SSE or Streamable HTTP, with OAuth. Their tools and resources are exposed to the model and go through the same permission path.
  • Agents, rules and skills. Custom agents are defined in config. Instructions come from AGENTS.md, or from CLAUDE.md unless that is disabled, and global and project files are both read. Skills are loaded into the system prompt.
  • Programmatic. You can use the HTTP API and @opencode-ai/sdk, opencode serve, opencode run, an ACP server, and a GitHub Action handler.

Running it

  • Install. Run curl -fsSL https://opencode.ai/install | bash, npm i -g opencode-ai, or Homebrew. The desktop app is a separate Electron build.
  • Configure a model. Run opencode providers login (alias auth) for any models.dev provider, or point an OpenAI-compatible provider at a local server.
  • Run headless. opencode serve starts the API server, and opencode run "..." does a one-shot non-interactive run.

Strengths and caveats

  • Strength: client/server split. Every UI, including the TUI, is a client of one server API. That makes remote, IDE and scripted use first-class.
  • Strength: provider breadth. It works with any AI SDK provider plus the models.dev metadata (limits, costs, modalities), with per-family system prompts and provider-specific message fixes.
  • Strength: forgiving edits. The nine-step matcher chain recovers from the whitespace and indentation slips that make strict search/replace fail.
  • Caveat: permissive defaults, no sandbox. "*": "allow" means bash and edits run unprompted unless you configure rules. Nothing confines commands at the OS level.
  • Caveat: one turn per loop iteration. Each step reloads history from SQLite and rebuilds the tools and system prompt. It is simple and robust, but it adds work to every model call.
  • Caveat: moving target. A native LLM runtime, background subagents, code mode and WebSockets are all behind experimental flags, and V1/V2 message schemas coexist in the code.

Sources: code at 4ac0d9c, verified Q&A.

How it answers the Open-source coding agents questions

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

How is the agent loop implemented?

answered

Agent Loop

The agent loop is a single-loop architecture — there is no separate Planner; the model calls tools directly until it decides to stop. The main loop is runLoop in packages/opencode/src/session/prompt.ts (line 1081-1341), which runs inside a while(true) controlled by SessionRunState.ensureRunning (line 1343-1347). Each iteration (step): loads messages from the database, resolves the model and agent, assembles tools via SessionTools.resolve, prepares system prompts (environment, instructions, skills, MCP), and calls LLM.stream which delegates to either the native LLM runtime or the AI SDK (session/llm.ts lines 226-269). The stream events are processed by SessionProcessor.process (processor.ts lines 641-697), which handles tool calls, reasoning, text deltas, and step completion via an event handler (handleEvent, lines 278-551).

Tool-call schema uses the Vercel AI SDK's Tool type (ai package) — tools emit tool-input-start/delta/end, tool-call, tool-result, and tool-error events. Tools execute via SessionTools.resolve (session/tools.ts), which iterates the ToolRegistry to get built-in tools (edit, write, bash, read, grep, glob, task, etc.) and MCP-provided tools. MCP tools' schemas are normalized through ProviderTransform.schema (provider/transform.ts line 1575).

Stop conditions: the loop breaks when the model returns a finish reason other than "tool-calls" or "unknown" (line 1111-1116), or when the processor returns "stop" (line 1319), or when compaction creates a loop (line 1166-1168). If the processor returns "compact", a compaction step is created and the loop continues (line 1321-1328).

Sub-agents use the task tool (tool/task.ts), which spawns a new session with a different agent via handleSubtask (prompt.ts lines 255-449). Agents like general, explore, and custom agents are defined in agent/agent.ts (lines 140-265) with distinct permission rulesets and system prompts. Background sub-agents use BackgroundJob service for async execution.

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

answered

Context Management

OpenCode gathers repository context through dedicated tools rather than embedding a full repo map directly in the prompt. The read tool (tool/read.ts) reads file contents with optional line ranges. The grep tool (tool/grep.ts) does regex content search. The glob tool (tool/glob.ts) does pattern-based file listing. The list tool provides directory listings. There is also an MCP resource system that can fetch context from connected servers — list_mcp_resources, list_mcp_resource_templates, and read_mcp_resource (session/tools.ts lines 139-386).

System prompts are assembled per step in runLoop (prompt.ts lines 1257-1270): environment info, agent instructions from AGENTS.md-style files (instruction.ts), MCP instructions, and skills. The environment prompt in session/system.ts (lines 69-80) includes the working directory, workspace root, OS, current date/time, and the model ID. Per-model prompt variants exist for Anthropic, GPT, Gemini, Kimi, Meta, Codex, and a generic default (session/system.ts lines 28-51, session/prompt/ directory).

Context window overflow is detected by isOverflow in session/overflow.ts (lines 8-34), which checks if total tokens exceed the model's context limit minus a reserved buffer (default 20k). The SessionCompaction service (session/compaction.ts) handles overflow via pruning (lines 273-317) — walking backwards through recent tool calls, erasing the output text of older ones (marking compacted timestamp) to free space while keeping at least PRUNE_PROTECT (40k) tokens protected — and compaction (lines 319-557), which uses a dedicated "compaction" agent to summarize the conversation history. The compaction strategy selects a tail_start_id based on preserveRecentBudget (lines 115-119, default 2k-15k tokens reserved for recent turns), and the compaction agent generates a summary that prepends the next model call. If auto-compaction is enabled, after compaction a synthetic "Continue" message is injected (line 519-548).

There is no embedding-based RAG or vector search in the codebase; context retrieval relies entirely on tool calls (the model decides what to grep/read/glob) and compaction-based summarization of long sessions.

How are code edits applied?

answered

Code Editing

OpenCode provides three editing tools: edit, write, and apply_patch. Which tool is exposed to the model depends on the model ID — apply_patch is shown for GPT models while edit and write are used for others (tool/registry.ts lines 297-301).

Edit tool (tool/edit.ts): Takes filePath, oldString, newString, and optional replaceAll. It implements a sophisticated multi-strategy search/replace with 9 fallback replacers: SimpleReplacer (exact match), LineTrimmedReplacer (trimmed lines), BlockAnchorReplacer (first/last line anchors with Levenshtein similarity), WhitespaceNormalizedReplacer, IndentationFlexibleReplacer, EscapeNormalizedReplacer, TrimmedBoundaryReplacer, ContextAwareReplacer, and MultiOccurrenceReplacer (lines 694-704). Each replacer is tried in order until a unique match is found. The tool computes a unified diff before applying, shows the diff in permission prompts, normalizes line endings, handles BOM, and formats the file after writing. If the edited file has LSP diagnostics errors, they're reported back to the model (lines 196-201). A semaphore-based file lock prevents concurrent edits (lines 35-45).

Write tool (tool/write.ts): Takes filePath and content. Computes a diff against the existing file (or empty if new), shows it in a permission prompt, writes the file with directory creation, formats it, and reports LSP diagnostics (up to 5 files).

Apply Patch tool (tool/apply_patch.ts): Takes a full patchText in a custom patch format. Validates the patch, parses hunks (supports add, update, delete, move), verifies each file path against external directory checks, computes per-file diffs, asks for permission with the total diff, then applies all changes. After applying, runs LSP diagnostics across all touched files (lines 266-293).

Validation comes from LSP diagnostics after each write/edit operation — if LSP errors are found, the output tells the model to fix them. There is no automated test-run or lint-run after editing in the editing tools themselves (that's left to the model's discretion or a subsequent step).

Undo/git integration: The Snapshot service (snapshot/index.ts) tracks file patches via structuredPatch (diff library) keyed by a hash. Before starting LLM stream execution, a snapshot is captured (track(), line 102 in processor.ts). On step finish, the snapshot is compared and a patch part is persisted (processor.ts lines 471-483). The Session.revert field stores snapshot/diff info for undo operations (session.ts lines 209-214, session/revert.ts).

How are shell commands and file writes kept safe?

answered

Execution Safety

OpenCode uses a ruleset-based permission system with wildcard pattern matching. The Permission service (permission/index.ts) evaluates each tool call against a stack of rulesets. Rules have action in {"allow", "deny", "ask"}. For each tool execution, evaluate() (line 28-38) scans rulesets in reverse order and returns the first matching rule (defaulting to "ask"). If the action is "allow", execution proceeds silently. If "deny", a DeniedError is thrown. If "ask", a pending permission request is created and the user must reply (ask(), line 67-107). Approvals can be "once" or "always" (stored in the session's approved list for future auto-allow). Denials can include user feedback as a CorrectedError (line 125).

Permission rulesets are defined per-agent in agent/agent.ts. The build agent (default) allows most tools but "ask" for doom_loop, external directories, and .env file reads (lines 119-155). The plan agent denies all edit/write tools except plan files (lines 156-181). The explore agent denies everything except read-only tools (grep, glob, list, bash, read, webfetch, websearch) (lines 197-213). Custom agents and user-configured permissions merge on top of these defaults (lines 267-294).

Shell command safety (tool/shell.ts): The shell tool detects dangerous command types — it has sets for CWD (cd, pushd etc.), FILES (rm, cp, mv, chmod, chown, etc.), and potentially destructive commands. Output is truncated via the Truncate service. Timeouts are configurable (default 300s).

External directory checks: The assertExternalDirectoryEffect function (tool/external-directory.ts) prevents writing outside the project worktree unless explicitly allowed by the permission ruleset.

Doom loop detection in processor.ts (lines 353-381) checks if the same tool has been called with the same input 3+ times consecutively, and requires explicit user permission to continue.

Sandboxing/containers: There is no built-in container sandbox or Landlock/Seccomp integration visible in the codebase. Execution safety relies on the permission system operating at the tool-call level, not OS-level isolation. The Question tool provides structured input from the user. The codebase does not implement network egress filtering or allow/deny lists for network access beyond what the permission system covers.

Checkpoints via Snapshot (snapshot/index.ts): Before each LLM step, a git-based snapshot is taken (track()). On step completion, the diff is captured (patch()). The revert system allows rolling back snapshots.

Editor's note. Correction: the shell tool has no dangerous-command detector; its CWD/FILES sets only find paths outside the project for external_directory prompts, and each tree-sitter-parsed sub-command becomes a bash permission pattern. The default ruleset is "*": "allow", so shell and edits run unprompted unless configured.

Which models are supported and how are they called?

answered

Model Support

OpenCode uses the Vercel AI SDK (ai package) as its primary LLM abstraction layer, with an opt-in native LLM runtime (@opencode-ai/llm). The Provider service (provider/provider.ts) loads model configurations from a catalog (@opencode-ai/core/models-dev.ts), which defines models with fields for context limits, output limits, cost tiers, reasoning options, modalities (text/image/audio/pdf), temperature support, tool-call support, and per-provider npm packages.

Providers are resolved from the catalog and configured via @ai-sdk/* npm packages. The sdkKey() function in provider/transform.ts (lines 41-98) maps npm packages to AI SDK provider option keys: @ai-sdk/anthropic, @ai-sdk/openai, @ai-sdk/google, @ai-sdk/amazon-bedrock, @ai-sdk/mistral, @ai-sdk/groq, @ai-sdk/xai, @ai-sdk/cohere, @ai-sdk/perplexity, @ai-sdk/togetherai, @ai-sdk/azure, @ai-sdk/alibaba, @ai-sdk/cerebras, @ai-sdk/deepinfra, @ai-sdk/vercel, @ai-sdk/gateway, plus venice, openrouter, gitlab-ai-provider, and OpenAI-compatible providers via @ai-sdk/openai-compatible. Plugins add auth support for GitHub Copilot, Codex, GitLab, Poe, Cloudflare, Azure, DigitalOcean, Snowflake Cortex, xAI, Cerebras, and Modal.

Local models are supported via OpenAI-compatible endpoints (@ai-sdk/openai-compatible), enabling any local LLM server (like Ollama, LM Studio, or vLLM) that exposes an OpenAI-compatible API.

Tool-calling vs text formats: Models with tool_call: true in the catalog definition get full tool definitions. The AI SDK handles tool-calling natively; OpenCode normalizes the stream events through LLMAISDK.toLLMEvents (session/llm/ai-sdk.ts). For providers without tool-calling, the model would only produce text.

Per-model prompt tuning is in session/system.ts (lines 28-51): Anthropic models get one system prompt (anthropic.txt), GPT-4/o1/o3 get beast.txt, newer GPTs get astra.txt or codex.txt, Gemini gets gemini.txt, Kimi gets kimi.txt, Meta/Muse gets meta.txt, and everything else gets default.txt.

Provider-specific message transforms in provider/transform.ts handle quirks: Anthropic models filter empty content parts and normalize tool call IDs (lines 170-194, 224-251), Bedrock uses signature-based reasoning, Mistral pads/scrambles tool call IDs (lines 253-277), and OpenAI uses include for encrypted reasoning (line 23).

Cost tracking uses Session.getUsage() (session.ts lines 338-405), which reads cost tiers from the model catalog, handles cache read/write pricing, and accounts for provider-specific metadata keys (Anthropic vertex, Bedrock, Venice).

How can it be extended and customised?

answered

Extensibility

MCP (Model Context Protocol) is fully integrated via packages/opencode/src/mcp/. The MCP.Service manages MCP client connections supporting stdio, SSE, and Streamable HTTP transports (mcp/index.ts lines 1-80). MCP servers are configured in the user config and launched as child processes or connected via URL. MCP tools are automatically discovered and exposed to the LLM alongside built-in tools. The SessionTools.resolve function (session/tools.ts lines 390-491) wraps each MCP tool with permission checking, truncation, and event publishing. MCP resources can be listed and read via dedicated LLM tools (list_mcp_resources, read_mcp_resource, etc., lines 139-386).

Plugin system (plugin/index.ts): OpenCode loads plugins via two mechanisms: 1) Internal plugins — provider-specific auth plugins directly imported (Codex, Copilot, GitLab, Poe, Cloudflare, Azure, DigitalOcean, xAI, Cerebras, Snowflake Cortex, Modal) — see internalPlugins() (lines 67-86). 2) External plugins from npm or local files via the PluginLoader. Plugins can provide custom tools (registered via tool: hooks in the plugin module), extend system prompts, transform messages, modify tool executions, and hook into session lifecycle events. The Plugin.trigger() function fires hooks at named points like experimental.chat.system.transform, tool.execute.before, tool.execute.after, experimental.session.compacting, and experimental.text.complete (examples throughout processor.ts and prompt.ts).

Custom tools can be defined by placing {js,ts} files in {tool,tools}/ directories within config directories (tool/registry.ts lines 183-197). Each file exports tool definitions with args, description, and execute fields, which are automatically loaded and registered.

Rules/instruction files: The Instruction service loads instructions from AGENTS.md files in the project (the repo's AGENTS.md at root works as a per-repo instruction file). Skills are defined in .claude/sk.json or similar config.

Hooks are implemented via the Plugin system — the Hooks interface includes lifecycle callbacks for chat system transforms, message transforms, tool definitions, compaction, and more. Plugins can also act as Workspace Adapters via registerAdapter (control-plane/adapters/).

Headless/SDK use: OpenCode exports the @opencode-ai/sdk package (packages/sdk/) and @opencode-ai/plugin for programmatic use and plugin development. The opencode-ai npm package is designed for headless/CI use (curl -fsSL https://opencode.ai/install). The architecture separates core services (schema, protocol, core) from UI (TUI, web, desktop), enabling headless/server-mode operation.

Editor's note. Correction: skills are not defined in .claude/sk.json; they are directories with a SKILL.md, discovered by packages/opencode/src/skill/discovery.ts.