What is OpenCode?
OpenCode is an open-source AI coding agent that runs in the terminal. It reads your codebase, writes code, runs shell commands, searches the web, and reasons about problems. You pick the AI provider and model. You run it locally with your own API keys.
It is MIT licensed. The code is at github.com/anomalyco/opencode.
History
OpenCode started as a Go project built with Bubble Tea by Kujtim Hoxha. Dax Raad and the team at Anomaly (formerly Charm/Crush) rewrote it in TypeScript. The rewrite replaced the original architecture with a client-server model, event-sourcing persistence, a plugin system, and support for multiple frontends.
TypeScript was chosen for access to the Vercel AI SDK (provider abstraction), SolidJS (reactive terminal rendering), and Hono (HTTP server). It also enabled web and desktop frontends to share code with the core.
Key Numbers
What Makes It Different
- Provider-agnostic. Works with 75+ AI providers: Anthropic, OpenAI, Google, AWS Bedrock, Azure, Ollama, local models, and more. You can switch models mid-conversation.
- Open source. MIT license.
- Multiple interfaces. Terminal TUI, desktop app (Tauri), Electron app, IDE extensions (VS Code, Zed, JetBrains, Neovim, Emacs), web interface, Slack bot, and a console mode.
- Extensible. MCP servers, LSP integration, plugins with hooks, custom tools, custom agents, custom commands, custom skills, ACP support.
- Event-sourced. Every message, tool call, and response is stored as an event. This enables undo/redo, session replay, and debugging.
Architecture Overview
OpenCode follows a client-server architecture. When you run opencode, a Hono HTTP server starts and a client connects to it. The terminal TUI, web interface, and desktop app are all clients. They communicate with the server over HTTP and Server-Sent Events (SSE).
Monorepo Structure
OpenCode is a monorepo managed with Bun (package manager and runtime) and Turborepo (build orchestration). It contains 19+ packages:
| Package | Path | Purpose |
|---|---|---|
opencode |
packages/opencode/ |
Core CLI + engine: agent loop, tools, providers, persistence, config, server |
@opencode-ai/app |
packages/app/ |
SolidJS web app (browser UI) |
@opencode-ai/desktop |
packages/desktop/ |
Tauri desktop app |
@opencode-ai/desktop-electron |
packages/desktop-electron/ |
Electron desktop app |
@opencode-ai/ui |
packages/ui/ |
Shared UI component library |
@opencode-ai/web |
packages/web/ |
Marketing/docs website |
@opencode-ai/sdk |
packages/sdk/js/ |
TypeScript SDK for API clients |
@opencode-ai/plugin |
packages/plugin/ |
Plugin type definitions |
@opencode-ai/util |
packages/util/ |
Shared utilities |
@opencode-ai/function |
packages/function/ |
Serverless functions |
@opencode-ai/slack |
packages/slack/ |
Slack integration |
@opencode-ai/console |
packages/console/ |
Console web app |
@opencode-ai/storybook |
packages/storybook/ |
Storybook for UI components |
@opencode-ai/script |
packages/script/ |
Build/release scripts |
@opencode-ai/enterprise |
packages/enterprise/ |
Enterprise features |
@opencode-ai/identity |
packages/identity/ |
Identity/auth services |
@opencode-ai/containers |
packages/containers/ |
Container support |
docs |
packages/docs/ |
Documentation |
The core opencode package at packages/opencode/ contains everything: the agent loop, tool registry, provider integrations, persistence layer, HTTP server, and TUI. It is not split into separate core/server/tui packages.
The Effect Pattern
The codebase uses the Effect library heavily. Effect provides dependency injection and structured error handling for TypeScript.
Every major subsystem is an Effect Service: the database, the file system, the permission system, the LLM providers. Services declare their dependencies explicitly. The type system enforces that all dependencies are provided at runtime. The codebase uses ServiceMap.Service and InstanceState patterns rather than Effect.Service directly.
// Effect service pattern used throughout OpenCode
// Uses ServiceMap.Service and InstanceState patterns
export class Database extends Effect.Service<Database>() {
db: DrizzleDB
query: <T>(sql: SQL) => Effect<T>
}
// At boot, all services compose into a single layer
const appLayer = Layer.mergeAll(
DatabaseLive,
PermissionLive,
ProviderLive,
ToolRegistryLive,
)
Key Design Decisions
- Event sourcing. OpenCode stores every event (message sent, tool called, response received). Current state is derived by replaying events through projectors. This enables undo/redo and session replay.
- Pub/sub event bus. Components communicate through an event bus. The TUI subscribes to events from the server. The persistence layer subscribes to agent events. No direct coupling.
- Vercel AI SDK. A unified streaming interface across all LLM providers instead of provider-specific code.
- Client-server split. The server holds all state and logic. Clients are views. Multiple clients can connect to the same server. You can build new frontends without touching the core.
The Agent Loop
The agent loop is SessionPrompt.loop(). When you send a message, this function runs in a while loop: it builds the system prompt, streams the LLM response, executes any tool calls, and loops again. It exits when the LLM produces a response with no tool calls.
The Loop Visualized
Building the System Prompt
Every iteration assembles a system prompt from multiple sources (see Chapter 12 for full details):
- Provider-specific base prompt. Selected based on the model ID. Claude models get
anthropic.txt, GPT models getbeast.txtorgpt.txt, etc. - Environment block. Working directory, platform, date, git status, model name.
- Skills. Available skills listed in XML format.
- User instructions. From AGENTS.md, CLAUDE.md files (project-level and global).
- Structured output instruction. If applicable.
- User-provided system prompt. If set.
This assembly runs every iteration because context changes after tool execution.
Streaming the Response
LLM.stream() sends the full conversation to the provider and returns a stream of typed events. The stream produces these event types:
| Event Type | Description |
|---|---|
start | Stream has started |
reasoning-start | Begins a reasoning/thinking block |
reasoning-delta | Incremental reasoning content |
reasoning-end | Ends a reasoning block |
text-start | Begins a text response |
text-delta | Incremental text content |
text-end | Ends a text response |
tool-input-start | Begins streaming tool call arguments |
tool-input-delta | Incremental tool argument JSON |
tool-input-end | Ends tool call argument streaming |
tool-call | Complete tool call with name and parsed arguments |
tool-result | Result returned from tool execution |
tool-error | Error from tool execution |
start-step | Begins a new agent loop step |
finish-step | Ends a step, includes token usage |
error | Stream-level error occurred |
finish | Stream has completed |
for await (const part of stream.fullStream) {
switch (part.type) {
case "text-delta":
currentText += part.textDelta
break
case "reasoning":
currentReasoning += part.textDelta
break
case "tool-call":
toolCalls.push({
name: part.toolName,
args: part.args,
})
break
}
}
Tool Execution
After the stream completes, the loop checks for tool calls. If present, each tool is executed. Results are appended to the conversation as tool result messages, and the loop iterates again.
If no tool calls were made, the loop exits and returns the response.
Subtask Handling
When the task tool is called, a child session is spawned with its own agent loop. The parent agent can fire off multiple tasks in parallel and continue working. Each subtask runs as a full agent with its own tool access (determined by the sub-agent definition; see Appendix A). Task results are returned to the parent as tool results.
Doom Loop Detection
If the LLM makes the same tool call three times in a row (same tool, same arguments), the loop triggers Permission.ask() with the "doom_loop" permission. This prompts the user to approve continuing or stop the loop. It does not abort automatically. This prevents burning tokens when the LLM gets stuck retrying a failing operation, while still allowing the user to let it continue if the repetition is intentional.
Step Tracking and Usage
Each loop iteration is a "step." OpenCode tracks step count and token usage (input, output, cache hits) across all steps. This data is exposed in the UI.
Auto-Title Generation
After the first response in a new session, a background LLM call generates a short title (50 characters or fewer) using the title agent. This title appears in the session list.
Tool System
Tools are how the LLM interacts with the filesystem, shell, and external services. Without tools it is a chatbot. With tools it can read files, write code, run tests, search the web, and spawn sub-agents.
How Tools Are Defined
Every tool is created with Tool.define(). Each definition specifies a name, description, Zod input schema, execute function, and permission requirements.
Tool descriptions are loaded from .txt files alongside the tool source code. These text files support template variables that get interpolated at runtime (e.g., the current working directory, OS platform). This keeps long descriptions out of the TypeScript code and makes them easy to edit.
export const ReadTool = Tool.define({
name: "read",
description: "Read the contents of a file",
parameters: z.object({
filePath: z.string().describe("Absolute path to the file"),
offset: z.number().optional(),
limit: z.number().optional(),
}),
async execute({ filePath, offset, limit }) {
const content = await fs.readFile(filePath, "utf-8")
// apply offset/limit, return content
return { content }
},
})
Built-in Tools
| Tool | Description | Permission |
|---|---|---|
bash |
Execute shell commands in the project directory | Ask |
read |
Read file contents with optional line range | Allow |
write |
Create or overwrite files | Ask |
edit |
Targeted find-and-replace edits in files | Ask |
apply_patch |
Apply a unified diff patch to a file. Used instead of edit/write for GPT models. | Ask |
glob |
Find files matching a glob pattern | Allow |
grep |
Search file contents with regex | Allow |
task |
Spawn a sub-agent for parallel work | Allow |
webfetch |
Fetch and extract content from a URL | Ask |
websearch |
Search the web and return results. Conditional: only available for the opencode provider or when OPENCODE_ENABLE_EXA is set. |
Ask |
codesearch |
Semantic code search using embeddings. Conditional: only available for the opencode provider or when OPENCODE_ENABLE_EXA is set. |
Allow |
todowrite |
Create or update tasks in the task list | Ask |
question |
Ask the user a question. Registered conditionally for app/cli/desktop clients. | Ask |
lsp |
Query language servers (diagnostics, definitions, hover). Experimental: requires OPENCODE_EXPERIMENTAL_LSP_TOOL flag. |
Allow |
skill |
Load a skill file's contents into the conversation | Allow |
invalid |
Returned when the LLM calls a tool that does not exist. Provides an error message. | Allow |
batch |
Execute multiple tool calls in a single batch (experimental) | Varies |
plan_exit |
Exit plan mode and return to build mode (experimental) | Allow |
Tool Filtering per Model
Not all models get the same tool set. GPT models receive apply_patch instead of edit and write, because GPT models perform better with unified diffs than with the find-and-replace style of edit. The tool set is filtered based on the model ID before being sent to the LLM.
The Permission Model
Permissions have three levels:
- Allow - Runs immediately. Used for read-only operations.
- Ask - Requires user approval. Used for write operations and external interactions.
- Deny - Blocked entirely.
Permissions are stored as a Ruleset, an array of rules. Each rule has a tool name and a glob pattern to match against the tool's arguments. Rules are evaluated in order; the first match wins.
{
"permissions": {
"bash": {
"allow": ["npm test*", "npm run lint"],
"deny": ["rm -rf *"]
}
}
}
When a tool call comes in, the permission system serializes the tool arguments into a string and matches it against the Ruleset patterns. If no rule matches, the tool's default permission level applies.
Custom Tools
Custom tools are loaded from .opencode/tool/*.{js,ts} or .opencode/tools/*.{js,ts} at startup. They are registered alongside built-in tools and appear to the LLM the same way. This is useful for project-specific workflows like deploying, running custom test suites, or calling internal APIs.
The Task Tool (Sub-agents)
The task tool spawns a child session with its own agent loop. The parent can fire off multiple tasks in parallel. Each task is a full agent with tool access defined by its agent configuration (the general sub-agent by default, or explore for read-only searches). Sub-tasks can spawn their own sub-tasks.
Provider System
OpenCode supports 75+ AI providers through an abstraction layer built on the Vercel AI SDK. Each provider has its own SDK package. The Vercel AI SDK wraps them in a unified streamText() interface. OpenCode adds a thin layer on top for token tracking, event emission, and error handling.
Provider SDK Packages
These are the SDK packages OpenCode uses:
| Provider | SDK Package |
|---|---|
| Anthropic | @ai-sdk/anthropic |
| OpenAI (Responses API) | @ai-sdk/openai |
| Google (AI Studio) | @ai-sdk/google |
| Google Vertex AI | @ai-sdk/google-vertex |
| Claude via Vertex | @ai-sdk/google-vertex/anthropic |
| AWS Bedrock | @ai-sdk/amazon-bedrock |
| Azure OpenAI | @ai-sdk/azure |
| xAI (Grok) | @ai-sdk/xai |
| Mistral | @ai-sdk/mistral |
| Groq | @ai-sdk/groq |
| OpenRouter | @openrouter/ai-sdk-provider |
| DeepInfra | @ai-sdk/deepinfra |
| Cerebras | @ai-sdk/cerebras |
| Cohere | @ai-sdk/cohere |
| Together AI | @ai-sdk/togetherai |
| Perplexity | @ai-sdk/perplexity |
| Vercel | @ai-sdk/vercel |
| GitLab | gitlab-ai-provider |
| GitHub Copilot | Custom Copilot SDK |
| OpenAI-compatible (generic) | @ai-sdk/openai-compatible |
| AI Gateway | @ai-sdk/gateway |
| Ollama / OpenAI-compatible | @ai-sdk/openai (with custom baseURL) |
Model Catalog
OpenCode maintains a model catalog sourced from models.dev. It stores: context window size, tool use support, extended thinking support, pricing per token, and max output tokens. This metadata drives context management, auto-compact thresholds, and cost display.
LLM.stream() Internals
When the agent loop calls LLM.stream():
- The provider and model are resolved from config.
- The model is wrapped with
wrapLanguageModelmiddleware. This middleware layer handles token tracking, event emission, and provider-specific adjustments. - For LiteLLM proxy compatibility, a dummy
_nooptool is injected. Some proxies fail when no tools are provided, so this ensures the tools array is never empty. streamText()from the Vercel AI SDK is called with the wrapped model, system prompt, messages, and tool schemas.- The resulting stream and usage metadata are returned.
async function stream(options) {
const provider = resolveProvider(options.model)
const wrapped = wrapLanguageModel(provider, middleware)
const result = await streamText({
model: wrapped,
system: options.systemPrompt,
messages: options.messages,
tools: options.toolSchemas,
maxTokens: options.maxTokens,
})
return {
fullStream: result.fullStream,
usage: result.usage,
}
}
Provider-Specific Handling
- Anthropic gets beta headers for prompt caching and extended thinking. The system prompt uses
anthropic.txtwhich is optimized for Claude. - OpenAI uses the Responses API (
sdk.responses(modelID)) for the direct OpenAI provider. The Chat Completions path is used for Copilot and Azure. - Google Gemini can return multiple tool calls in a single message chunk.
- Local models via Ollama use the OpenAI-compatible SDK with a custom
baseURL.
You can switch models mid-conversation. The conversation history is serialized and continues with the new model.
The Server & API
OpenCode runs a Hono HTTP server on a local port. All clients -- including the built-in TUI -- communicate with this server. This is what enables multiple frontends to connect to the same running instance.
Server Configuration
- CORS: Allows
localhost,http://127.0.0.1:*,tauri://localhost,http://tauri.localhost,https://tauri.localhost,*.opencode.aiorigins, and custom origins viaopts.cors. - Auth: Optional basic auth via the
OPENCODE_SERVER_PASSWORDenvironment variable. - Compression: Gzip compression enabled.
- Instance bootstrapping: A middleware initializes the project instance (database, config, tools) before handling requests.
API Routes
The full route table is in Appendix C. Here is the high-level grouping:
SSE Event Streaming
Clients connect to GET /event for a Server-Sent Events stream scoped to the current session. GET /global/event provides a global stream across all sessions. Events pushed down this stream include:
message.updated- A message part was updated (text streaming in, tool result arrived)part.updated,part.delta- Part-level updates and incremental contentsession.created,session.updated- Session lifecyclepermission.asked- A tool needs user approval
The TUI, web interface, and desktop app all subscribe to the same event stream. All frontends stay in sync.
Multi-Instance Support
Each project directory gets its own server on a different port, with its own database and session list. The port is determined by hashing the project directory path, so reopening the same project always gets the same port.
The SDK Client
The @opencode-ai/sdk package provides a TypeScript client for the server API. It supports two transport modes:
- HTTP transport. For external clients connecting over the network.
- In-process transport. For the TUI, which runs in the same process as the server. Skips the network layer.
import { OpenCode } from "@opencode-ai/sdk"
const client = new OpenCode({ url: "http://localhost:3000" })
const session = await client.session.create()
await client.message.create(session.id, {
content: "Refactor the auth module",
})
client.on("message.updated", (msg) => {
console.log(msg.content)
})
The Terminal UI
The terminal UI is the primary interface for OpenCode. It is built on OpenTUI, a custom terminal rendering framework that uses SolidJS for reactive updates.
OpenTUI
OpenTUI uses SolidJS instead of a virtual DOM. When state changes, SolidJS updates only the terminal cells that depend on that state. This avoids full re-renders.
OpenTUI provides:
- A component model (JSX components that render to terminal cells)
- Flexbox-like layout for positioning
- Reactive state via SolidJS signals
- Keyboard event handling
- Text wrapping, scrolling, and ANSI color support
Worker Architecture
The server runs in a separate worker process with an RPC bridge. This keeps the server and TUI rendering on different threads. The TUI communicates with the worker via RPC calls, and the worker sends events back. This prevents heavy LLM streaming or tool execution from blocking terminal rendering.
Component List
The TUI is composed of these dialog and UI components:
| Component | Purpose |
|---|---|
dialog-agent | Agent/mode selection dialog |
dialog-command | Command palette (fuzzy-searchable) |
dialog-mcp | MCP server management |
dialog-model | Model picker |
dialog-provider | Provider configuration |
dialog-session-list | Session browser |
dialog-skill | Skill selection and preview |
dialog-status | Status information display |
dialog-theme-list | Theme picker |
dialog-workspace-list | Workspace browser |
prompt | Input prompt area with autocomplete |
Provider Architecture
The TUI uses a provider pattern (like React context) to inject services into the component tree:
| Provider | What It Does |
|---|---|
| Theme | Colors, styling, visual configuration |
| SDK | Connection to the OpenCode server. Batches events at 16ms intervals for rendering efficiency. |
| Sync | Real-time state synchronization via SSE events. The SyncProvider mirrors server state locally so the TUI has a reactive local copy of all sessions, messages, and parts. |
| Dialog | Modal dialog management |
| Command | Command palette and slash command registry |
| Keybind | Keyboard shortcut management |
How the TUI Connects to the Server
The TUI connects via the in-process SDK transport. It subscribes to the SSE event stream and maintains a local reactive store. When you type a message, it sends POST /session/:id/prompt. SSE events stream in and update the UI in real-time: text appears incrementally, tool calls show progress, permission requests appear as dialogs.
Build and Plan Modes
Switch between modes with Tab:
- Build mode. Full tool access. Can read, write, edit files, run commands, spawn tasks.
- Plan mode. Read-only. Can read files and search, but cannot modify anything. Can only edit plan files.
The Command Palette
Press Ctrl+K / Cmd+K to open the command palette. It provides fuzzy-searchable access to: model switching, agent selection, session management, configuration, and all registered commands.
Data & Persistence
OpenCode stores all state in SQLite via Drizzle ORM. The database lives at ~/.local/share/opencode/opencode.db (or opencode-{channel}.db for non-default channels). SQLite is configured with WAL mode, NORMAL sync, 5-second busy timeout, 64MB cache, and foreign keys enabled.
The persistence layer uses event sourcing: instead of storing current state directly, it stores every event that occurred. Current state is computed by replaying events through projector functions that materialize the data into relational tables.
Database Schema
There are 8 tables. The three core tables (session, message, part) store conversation data. The message and part tables use a data JSON column for type-specific fields, with only the IDs and foreign keys as dedicated columns.
| Table | Columns | Purpose |
|---|---|---|
session |
id, project_id (FK), workspace_id, parent_id, slug, directory, title, version, share_url, summary_additions, summary_deletions, summary_files, summary_diffs (JSON), revert (JSON), permission (JSON Ruleset), time_created, time_updated, time_compacting, time_archived | Each conversation. parent_id links subtask sessions to their parent. revert stores undo state. |
message |
id, session_id (FK, cascade), time_created, time_updated, data (JSON) | Messages in a session. The data JSON contains: role (user/assistant), tokens, cost, error, parentID (links assistant replies to user messages), modelID, finish reason, summary flag. |
part |
id, message_id (FK, cascade), session_id, time_created, time_updated, data (JSON) | Typed parts within a message. The data JSON contains the part type and all type-specific fields. |
todo |
session_id, content, status, priority, position | Task list items. Composite PK: (session_id, position). |
permission |
project_id (PK), data (JSON Ruleset), time_created, time_updated | Per-project permission rules. |
project |
id, name, worktree | Registered projects. |
event_sequence |
aggregate_id (PK), seq | Tracks the latest sequence number per aggregate (session). |
event |
id, aggregate_id (FK, cascade), seq, type, data (JSON) | The event log. Every state change recorded as a typed event. |
Messages and Parts
The core data model is: a session contains messages, and each message contains parts. A single assistant message typically contains multiple parts — some text, possibly reasoning, and one or more tool calls.
Messages carry metadata in their data JSON: the role, token counts, cost, error state, the model ID used, finish reason, and a parentID field that links assistant messages back to the user message that triggered them. Assistant messages also have a summary boolean flag (true for compaction summary messages) and a finish flag (true when the response is complete).
The Part Type System
Every part has a base of { id, sessionID, messageID } plus type-specific fields:
| Part Type | Fields | Description |
|---|---|---|
TextPart |
text, synthetic?, ignored?, time, metadata? |
Plain text from the LLM. synthetic marks injected text (e.g. mode-switch reminders). ignored excludes it from LLM conversion. |
ReasoningPart |
text, time, metadata? |
Extended thinking/reasoning content (e.g. Claude's thinking blocks). |
ToolPart |
callID, tool, state, metadata? |
A tool invocation. The state field is a discriminated union that transitions through a state machine (see below). |
FilePart |
mime, filename?, url, source? |
File attachment. Source can be a file path, an LSP symbol reference, or an MCP resource. |
CompactionPart |
auto, overflow? |
Marks a compaction boundary on a user message. Messages before this are hidden when loading conversation for the LLM. |
SubtaskPart |
prompt, description, agent, model?, command? |
A pending subtask to be executed by the task tool. |
SnapshotPart |
snapshot |
Git tree hash representing filesystem state at a point in time. |
PatchPart |
hash, files[] |
Records which files changed between two snapshots. hash is the "before" tree hash. Used for revert. |
AgentPart |
name, source? |
An agent mention/reference. |
RetryPart |
attempt, error |
Records an API retry. Error includes statusCode, isRetryable, responseHeaders. |
StepStartPart |
snapshot? |
Marks the beginning of an agent loop step. Snapshot is the git tree hash at step start. |
StepFinishPart |
reason, snapshot?, cost, tokens |
Marks end of a step. Tokens includes: input, output, reasoning, cache.read, cache.write. |
ToolPart State Machine
The state field on a ToolPart is a discriminated union on status that transitions through four states:
State fields by status:
- pending:
input: {},raw: ""— created attool-input-startstream event. Raw input string accumulates viatool-input-delta. - running:
input(parsed args),title?,metadata?,time.start— transitions attool-callevent when input is fully parsed. - completed:
input,output,title,metadata,time.start,time.end,time.compacted?,attachments?— transitions attool-result. Thetime.compactedfield is set later during pruning. - error:
input,error(string),metadata?,time.start,time.end— transitions attool-error, or when the stream ends with tools still in pending/running state (set to "Tool execution aborted").
How Parts Are Created During Streaming
SessionProcessor.create() iterates over the LLM stream and creates/updates parts in real time:
reasoning-start→ creates aReasoningPartwith empty text andtime.start = Date.now()reasoning-delta→ appends text, publishes delta event to the bus for UI updatesreasoning-end→ trims text, setstime.endtext-start→ creates aTextPartwith empty texttext-delta→ appends text, publishes deltatext-end→ trims text, runs plugin hookexperimental.text.complete, setstime.endtool-input-start→ createsToolPartinpendingstatetool-call→ transitions torunning, checks for doom loop (3 identical calls triggers permission ask)tool-result→ transitions tocompletedwith output, title, metadata, attachmentstool-error→ transitions toerrorstart-step→ callsSnapshot.track()to capture filesystem state, createsStepStartPartfinish-step→ computes token usage and cost, createsStepFinishPart, createsPatchPartif files changed since the step's snapshot
All part creation and updates go through Session.updatePart(), which calls SyncEvent.run(MessageV2.Event.PartUpdated, ...). This persists the event and runs the projector to upsert the part row.
Converting Messages to LLM Format
MessageV2.toModelMessages() converts stored messages and parts into the format expected by the Vercel AI SDK:
- User messages: TextParts become
{ type: "text", text }. FileParts become{ type: "file", url, mediaType }. CompactionParts become{ type: "text", text: "What did we do so far?" }. - Assistant messages: TextParts and ReasoningParts map directly. Completed ToolParts include the output (or
"[Old tool result content cleared]"iftime.compactedis set). Error ToolParts include the error text. Pending/running ToolParts become"[Tool execution was interrupted]". - Skipped messages: Assistant messages with errors are skipped entirely, unless the error is an
AbortedErrorand the message has substantive parts.
For providers that don't support media in tool results (anything other than Anthropic, OpenAI, Bedrock, Vertex, or Gemini 3+), media attachments are extracted from tool results and injected as a separate user message after the assistant message.
Loading Messages: filterCompacted
MessageV2.filterCompacted() loads messages for the LLM, respecting compaction boundaries. It iterates messages in reverse chronological order (newest first). When it finds an assistant message that is a completed summary (summary: true, has finish, no error), it records that message's parentID. When it then reaches the corresponding user message and that message has a CompactionPart, it stops. Everything older is excluded.
The result is: the compaction user message + its summary response + all subsequent messages. The summary replaces all prior conversation history.
Event Sourcing
Events are defined with SyncEvent.define({ type, version, aggregate, schema }). The aggregate field names the key used as the aggregate ID (always "sessionID" for session events).
Seven event types exist:
| Event Type | Data | Projector Action |
|---|---|---|
session.created |
sessionID, info (full Session.Info) | INSERT into session table |
session.updated |
sessionID, info (partial Session.Info) | UPDATE session row (only changed fields) |
session.deleted |
sessionID, info | DELETE session row (cascades to messages, parts) |
message.updated |
sessionID, info (MessageV2.Info) | UPSERT into message table (insert or update on conflict) |
message.removed |
sessionID, messageID | DELETE message row |
message.part.updated |
sessionID, part (full Part), time | UPSERT into part table |
message.part.removed |
sessionID, messageID, partID | DELETE part row |
SyncEvent.run() executes within an immediate SQLite transaction: reads the current sequence number for the aggregate, increments it, runs the projector (which does the actual INSERT/UPDATE/DELETE), optionally persists the event to the event table (when workspace sync is enabled), then emits the event on the bus after commit.
Events can be replayed via SyncEvent.replay() for workspace sync. Replay checks sequence ordering and silently skips already-applied events (idempotent).
Fork, Revert, and Undo
Fork (Session.fork()) creates a new session and copies messages up to a specified point. Each message and part gets a new ID. The parentID links on assistant messages are remapped to the new message IDs.
Revert (SessionRevert.revert()) restores files to a previous state without deleting messages:
- Walks all messages to find the revert target (by messageID and optional partID)
- Collects all
PatchParts that occur after the target - Takes a snapshot of current state
- For each file in each patch: checks out the file from the patch's git tree hash, or deletes it if it didn't exist at that point
- Stores revert metadata on the session:
{ messageID, partID?, snapshot?, diff? }
Cleanup happens when the user sends the next message after a revert. Messages after the revert point are deleted via SyncEvent.run(MessageV2.Event.Removed). If the revert was mid-message, parts from the revert point onward are also deleted.
Unrevert (SessionRevert.unrevert()) restores the full git tree to what it was before the revert, then clears the revert field.
Snapshots: The Shadow Git Repo
OpenCode maintains a shadow Git repository separate from your working repo. It lives at <data-dir>/snapshot/<projectID>/<hash(worktree)>/. All operations are serialized per directory using a semaphore.
Operations:
init()— creates the git directory withautocrlf=false,longpaths=true,symlinks=true,fsmonitor=false.track()— finds changed/new files viagit diff-filesandgit ls-files --others, filters out files over 2MB, runsgit addandgit write-tree. Returns the tree hash.patch(hash)— stages current state, diffs against the given tree hash, returns the list of changed files.restore(snapshot)— runsgit read-tree+git checkout-index -a -fto restore the full working tree.revert(patches)— selective per-file revert:git checkout <hash> -- <file>for each file, or deletes if the file didn't exist.diff(hash)/diffFull(from, to)— computes diffs between tree hashes, returning structuredFileDiff[]with before/after content.cleanup()— runsgit gc --prune=7.days, scheduled hourly.
Context Window Management
Every LLM has a context window limit. In a long coding session with many tool calls, you can hit it fast. OpenCode manages this automatically with a two-phase system.
Overflow Detection
Before each loop iteration, OpenCode checks whether the context is approaching the limit. The formula:
overflow = totalTokens >= inputLimit - reserved
// reserved = min(20_000, maxOutputTokens)
inputLimit is the model's context window size minus max output tokens. reserved defaults to the smaller of 20,000 tokens or maxOutputTokens. If totalTokens exceeds this threshold, the two-phase cleanup runs.
Token counting uses a heuristic: Math.round(string.length / 4). This approximation avoids the cost of running a full tokenizer on every message.
Token Thresholds
Three constants control pruning and compaction behavior:
- PRUNE_PROTECT = 40,000 tokens. The most recent 40K tokens of conversation are never pruned.
- PRUNE_MINIMUM = 20,000 tokens. A tool output must be at least 20K tokens old (measured from the end of conversation) before it is eligible for pruning.
- COMPACTION_BUFFER = 20,000 tokens. Buffer reserved during compaction to ensure the summary fits within the context window.
The Two-Phase Approach
Phase 1: Pruning
Pruning does not call the LLM. It finds old tool call results (older than PRUNE_MINIMUM tokens from the end of conversation) and replaces their output with a placeholder like [output pruned]. The tool call record stays in the history so the LLM knows a tool was used, but the large result body is removed.
The most recent PRUNE_PROTECT tokens (40K) are never pruned.
Phase 2: Compaction
If pruning is not enough, the older portion of the conversation is sent to the compaction agent. This agent produces a summary that captures the goal, key decisions, discoveries, and current state. The summary is inserted as a CompactionPart at the beginning, and the old messages it summarizes are removed.
The compaction summary follows a structured template (see Appendix B).
Plugin Hook
The experimental.session.compacting plugin hook fires before compaction. Plugins can use this to customize compaction behavior or inject additional context into the summary.
Extensibility
OpenCode is designed to be extended at every level: custom tools, custom agents, custom commands, plugins with hooks, MCP servers, LSP integration, and ACP support.
MCP (Model Context Protocol)
OpenCode has first-class MCP support. Three transport types are supported:
- StdioClientTransport. Runs a local process. Communicates over stdin/stdout.
- StreamableHTTPClientTransport. Connects to a remote HTTP server with streaming.
- SSEClientTransport. Connects to a remote server using Server-Sent Events.
MCP servers that require authorization are handled via OAuth PKCE flow (RFC 7591 dynamic client registration). The PKCE flow opens a browser for authorization and receives the callback on a local server.
MCP tools are namespaced: each tool is prefixed with the MCP server name to avoid collisions with built-in tools or tools from other servers.
OpenCode subscribes to ToolListChangedNotification from MCP servers. When a server adds, removes, or modifies tools, the tool registry is updated dynamically without requiring a restart.
{
"mcp": {
"servers": {
"database": {
"type": "stdio",
"command": "npx",
"args": ["@modelcontextprotocol/server-postgres"]
},
"github": {
"type": "sse",
"url": "https://mcp.github.com/sse"
}
}
}
}
Once connected, MCP tools appear alongside built-in tools. The LLM uses them the same way.
LSP Integration
OpenCode auto-detects language servers (tsserver, pyright, gopls, etc.) and connects to them. The agent gets access to diagnostics, hover information, go-to-definition, and symbol search. LSP diagnostics are injected into the system prompt so the agent knows about current errors.
Plugins
Plugins hook into lifecycle events. The full list of hooks:
| Hook | When It Fires |
|---|---|
chat.params | Before an LLM call. Can modify system prompt, parameters. |
chat.headers | Before an LLM HTTP request. Can add/modify headers. |
tool.definition | When tool definitions are assembled. Can modify tool schemas. |
tool.execute.before | Before a tool runs. Can validate or modify arguments. |
tool.execute.after | After a tool runs. Can post-process results. |
experimental.chat.system.transform | Transform the system prompt before sending. |
experimental.chat.messages.transform | Transform the message array before sending. |
experimental.text.complete | Text completion hook for autocomplete. |
experimental.session.compacting | Before compaction runs. |
Built-in Plugins
OpenCode ships with authentication plugins for specific providers:
- CodexAuthPlugin - Handles OpenAI Codex authentication
- CopilotAuthPlugin - Handles GitHub Copilot OAuth flow
- GitlabAuthPlugin - Handles GitLab authentication
- PoeAuthPlugin - Handles Poe authentication
Custom Commands
Markdown files in .opencode/command/**/*.md or .opencode/commands/**/*.md register as slash commands. Each file supports YAML frontmatter for metadata (name, description, parameters). The file's content becomes the prompt sent to the agent when the command is invoked. Commands appear in the command palette.
Custom Agents
Custom agents can be defined in two ways:
- Markdown files in
.opencode/agent/**/*.mdor.opencode/agents/**/*.md. Each file uses YAML frontmatter to specify the agent name, description, tool access rules, and model preferences. The file body becomes the agent's system prompt. - JSON configuration in the
.opencode/config.jsonfile under theagentskey. This allows defining agents with programmatic tool access rules.
Custom agents appear alongside the built-in build and plan agents in the agent picker.
Custom Tools
JavaScript or TypeScript files in .opencode/tool/*.{js,ts} or .opencode/tools/*.{js,ts} are loaded at startup and registered as tools.
IDE Extensions
OpenCode has extensions for VS Code, Zed, JetBrains, Neovim, and Emacs. These connect to the same OpenCode server and share sessions, tools, and config.
The VS Code extension is installed via code --install-extension sst-dev.opencode. OpenCode detects IDE environments through the TERM_PROGRAM and GIT_ASKPASS environment variables.
ACP (Agent Client Protocol)
ACP allows agents to communicate with each other. OpenCode can be orchestrated by a larger system or orchestrate other ACP-compatible agents.
Desktop & Beyond
The client-server architecture means OpenCode can run anywhere a browser or native app can.
Tauri Desktop App
The primary desktop app uses Tauri (Rust-based). It wraps the web interface in a native window.
- Starts the OpenCode server as a child process
- Opens a native window with the web UI
- The web UI connects to the local server over HTTP + SSE
- On close, it shuts down the server
Provides native OS integration: system notifications, file dialogs, menu bar, keyboard shortcuts.
Electron Desktop App
An Electron-based desktop app is also available as an alternative to Tauri. It provides the same web UI in an Electron shell.
Web Interface
The web interface is a SolidJS + Tailwind application in the @opencode-ai/app package. It provides markdown rendering, syntax-highlighted code diffs, file tree views, and works with any OpenCode server.
Slack Integration
A Slack bot connects to an OpenCode server. You message the bot in a channel or DM, and it responds with full agent capabilities including tool use and streaming. Useful for team collaboration -- everyone in the channel sees the agent work in real-time.
Console App
A stripped-down console mode for environments where the full TUI is not available (CI/CD pipelines, basic SSH sessions). Works with plain text input/output.
IDE Extensions
Extensions for VS Code, Zed, JetBrains, Neovim, and Emacs. They are alternative frontends that connect to the same server.
VS Code: code --install-extension sst-dev.opencode. OpenCode detects IDE environments via TERM_PROGRAM and GIT_ASKPASS environment variables to adjust behavior (e.g., opening files in the editor instead of printing contents).
The SDK
Everything above is built on the @opencode-ai/sdk package. It provides full TypeScript types, session management, message streaming, SSE event subscription, permission handling, and configuration management. Use it to build custom frontends.
System Prompt Construction
The system prompt is assembled dynamically every iteration of the agent loop. It determines how the LLM behaves, what it knows about, and what instructions it follows. This chapter covers the exact assembly process.
Provider-Specific Base Prompts
OpenCode selects a base prompt based on the model ID. Prompt files live in src/session/prompt/:
| File | Matched When | Key Characteristics |
|---|---|---|
anthropic.txt |
Model ID contains "claude" | Opens with "You are OpenCode, the best coding agent on the planet." Covers TodoWrite task management, Task tool usage, code reference format. |
beast.txt |
Model ID contains "gpt-4", "o1", or "o3" | Aggressive autonomous behavior: "MUST iterate and keep going until the problem is solved." Requires internet research. Pushes GPT models to be more autonomous because they tend to ask for permission too often. |
codex.txt |
Model ID contains "gpt" AND "codex" | Stripped-down. Prefers apply_patch. ASCII-only output. Git hygiene rules. |
gpt.txt |
Model ID contains "gpt" (after codex check) | Commentary/final channel system. Varied personality. |
gemini.txt |
Model ID contains "gemini-" | Long prompt with core mandates, editing constraints, new application workflow. |
trinity.txt |
Model ID contains "trinity" | Copilot-style formatting. File reference rules. |
default.txt |
Fallback (no other match) | "You are opencode, an interactive CLI tool..." Emphasizes conciseness ("fewer than 4 lines"), proactiveness rules, "DO NOT ADD ANY COMMENTS". |
Selection logic checks the model ID in this order: "claude" -> "gpt-4"/"o1"/"o3" (beast) -> "gpt"+"codex" -> "gpt" -> "gemini-" -> "trinity" -> default.
The prompt selection acts as counter-steering: different prompts compensate for each model's behavioral tendencies. For example, beast.txt pushes GPT models to be more autonomous because they tend to ask for permission too often, while anthropic.txt gives Claude models more structured guidance.
Assembly Order
The final system prompt is composed in this order:
- Agent prompt (if custom) OR provider-specific base prompt. If the current agent has a custom prompt defined, that is used. Otherwise the provider-specific prompt from the table above.
- Environment block. Model name, working directory, worktree root, platform, date, git status.
- Skills listing. Available skills in XML format.
- User instructions. AGENTS.md, CLAUDE.md files from project and global paths.
- Structured output instruction. If the agent requires structured output.
- User-provided system prompt. If set in config.
Environment Block Format
You are powered by the model named {model.api.id}. The exact model ID is {providerID}/{model.api.id}
Here is some useful information about the environment you are running in:
<env>
Working directory: {Instance.directory}
Workspace root folder: {Instance.worktree}
Is directory a git repo: {yes|no}
Platform: {process.platform}
Today's date: {new Date().toDateString()}
</env>
Instruction Loading
OpenCode searches for instruction files named AGENTS.md, CLAUDE.md, and CONTEXT.md (deprecated). These are loaded from two locations:
- Project. Walks up from the current working directory to the worktree root, collecting every matching file found along the way.
- Global. Checks
~/.config/opencode/and~/.claude/.
Contextual Instructions
When the agent reads a file during a session, OpenCode walks up from that file's directory looking for AGENTS.md and CLAUDE.md files. Any found instructions are injected into the system prompt. This allows different parts of a codebase to have their own instructions that activate only when those directories are being worked on.
Mid-Loop User Messages
If the user sends a message while the agent is still processing (mid-loop), queued user messages are wrapped in <system-reminder> tags and injected as synthetic text parts into the conversation. This allows the user to provide corrections or additional context without waiting for the agent to finish.
Skills Format in Prompt
Skills are listed in the prompt using XML:
<available_skills>
<skill>
<name>{name}</name>
<description>{description}</description>
<location>{file URL}</location>
</skill>
</available_skills>
Skills are markdown files in .opencode/skills/. They are not loaded into the prompt by default -- the agent sees the listing and uses the skill tool to load one when needed.
Provider Transform Layer
The provider system includes a transform layer that normalizes messages per-provider before sending to the LLM. Each provider has different requirements and quirks. The transform layer handles these differences so the rest of the codebase does not need to.
What the Transform Layer Does
- Empty content filtering. Removes messages with empty or whitespace-only content that would cause API errors.
- Tool call ID sanitization. Mistral requires tool call IDs to be exactly 9 alphanumeric characters. The transform layer rewrites IDs to conform.
- Synthetic message injection. Some providers need specific message patterns (e.g., a user message must precede an assistant message). The transform injects synthetic messages where needed.
- Interleaved reasoning extraction. Separates reasoning/thinking content from regular text for providers that handle them differently.
- Cache control headers. For Anthropic, adds cache control headers to enable prompt caching on long conversations.
- Schema sanitization. Google/Gemini has strict JSON schema requirements. The transform sanitizes tool parameter schemas to remove unsupported fields.
- Reasoning variants. Different providers expose reasoning/thinking differently. The transform normalizes these into a consistent format.
Temperature and Sampling Defaults
The transform layer sets per-provider temperature defaults when no explicit temperature is configured:
| Provider / Model | Default Temperature |
|---|---|
| Qwen | 0.55 |
| Gemini | 1.0 |
| Kimi-K2 | 0.6 |
These defaults are tuned based on empirical testing with each model family to produce the best coding agent behavior.
Edit Fuzzy Matching
The edit tool uses a find-and-replace approach: the LLM provides an "old" string and a "new" string, and the tool replaces the old with the new in the target file. The problem is that LLMs frequently produce output that does not exactly match the source file -- extra whitespace, different indentation, escape character differences, or slightly wrong context.
To handle this, the edit tool uses a 9-strategy replacer chain. Each strategy attempts to find the match using progressively more lenient matching. The first strategy that succeeds is used.
The Replacer Chain
| # | Strategy | What It Does |
|---|---|---|
| 1 | Simple | Exact string match. The fastest path -- used when the LLM output matches the file perfectly. |
| 2 | LineTrimmed | Trims trailing whitespace from each line before comparing. Handles the common case of trailing space differences. |
| 3 | BlockAnchor | Uses the first and last lines of the old string as anchors to find the block in the file. Everything between the anchors is replaced. |
| 4 | WhitespaceNormalized | Normalizes all whitespace (collapses runs of spaces/tabs) before comparing. Handles inconsistent whitespace. |
| 5 | IndentationFlexible | Allows different indentation levels. If the LLM uses 2-space indent but the file uses 4-space, this still matches. |
| 6 | EscapeNormalized | Handles escape character differences (e.g., \" vs ", different newline representations). |
| 7 | TrimmedBoundary | Trims leading and trailing empty lines from both the old string and the search area. |
| 8 | ContextAware | Uses surrounding context lines to locate the correct block even when the old string itself has differences. |
| 9 | MultiOccurrence | Handles cases where the old string appears multiple times in the file. Uses context to disambiguate which occurrence to replace. |
The chain is ordered from most strict to most lenient. Strict matches are preferred because they are less likely to produce false positives. The more lenient strategies only run if the stricter ones fail.
Error Handling & Resilience
LLM APIs fail. Networks drop. Providers rate-limit. OpenCode includes multiple resilience mechanisms to handle these failures gracefully.
Stream Processing Retry
When the LLM stream fails mid-response, OpenCode retries with exponential backoff. The delay formula is:
delay = min(2000 * 2^(attempt - 1), 30000)
// attempt 1: 2s, attempt 2: 4s, attempt 3: 8s, ..., capped at 30s
Retries only apply to errors classified as retryable (network errors, 429 rate limits, 500+ server errors). Non-retryable errors (400 bad request, 401 unauthorized) fail immediately.
Cross-Provider Overflow Detection
Different providers return context overflow errors in different formats. OpenCode maintains 15+ regex patterns to detect overflow errors across all supported providers. When an overflow is detected, the system triggers context compaction (see Chapter 9) and retries the request with the compacted context.
Error Classification
Errors are classified into categories that determine the response:
- Retryable. Network timeouts, rate limits (429), server errors (500+). Triggers automatic retry with backoff.
- Overflow. Context too large. Triggers compaction and retry.
- Auth. Invalid API key or expired token. Surfaces to the user for re-authentication.
- Fatal. Invalid request, unsupported operation. Fails immediately with an error message.
Abandoned Tool Call Cleanup
When a stream fails partway through, some tool calls may be in pending or running state. On stream failure, OpenCode transitions all incomplete tool calls to error state with the message "Tool execution aborted". This ensures the conversation history remains consistent and the LLM sees the failure on the next iteration.
Permission Blocking Handling
When a tool requires permission and the user has not yet responded, the agent loop blocks on that permission request. If the user denies permission, the tool result is set to an error explaining the denial. The LLM sees this and can choose an alternative approach.
Agent Definitions
OpenCode has seven built-in agents. Each has a mode, tool access rules, and sometimes a custom prompt.
| Agent | Mode | Description | Tool Access |
|---|---|---|---|
build |
primary | Default agent. Full tool access. This is what runs when you type a message normally. | All tools. question and plan_enter permissions allowed. |
plan |
primary | Read-only. Can only edit plan files. Used for analysis and planning without modifying the codebase. | Read-only tools. plan_exit allowed. Write tools denied. |
general |
subagent | For multi-step tasks spawned via the task tool. Full tool access minus todowrite. | All tools except todowrite (denied). |
explore |
subagent | Read-only file search specialist. Has a custom prompt optimized for finding information. | Only: grep, glob, list, bash, webfetch, websearch, codesearch, read. |
compaction |
primary (hidden) | Generates conversation summaries during context compaction. No tools. | All tools denied. |
title |
primary (hidden) | Generates conversation titles (50 chars or fewer). Temperature 0.5. No tools. | All tools denied. |
summary |
primary (hidden) | Generates PR-style summaries of conversations. No tools. | All tools denied. |
Hidden agents are not shown in the agent picker. They are used internally by the system.
Prompt Templates
Key prompt templates used by the internal agents and system features.
Compaction Prompt
The compaction agent receives a user message with this structure:
Summarize this conversation so far. Include:
**Goal**: What was the user trying to accomplish?
**Instructions**: What specific instructions or constraints did the user give?
**Discoveries**: What was learned along the way? (errors found, patterns identified, etc.)
**Accomplished**: What has been done so far?
**Relevant files**: List any files that were read, modified, or are relevant to the task.
The compaction agent's system prompt instructs it to produce a concise summary following this template. The result replaces the older messages it summarized.
Title Prompt
The title agent's system prompt:
You are a title generator. You output ONLY a thread title.
The title must be 50 characters or fewer.
No quotes. No punctuation at the end. No prefixes.
Temperature is set to 0.5 for slightly varied but focused output.
Mode-Switching Reminders
When switching between modes, reminder messages are injected:
- Plan mode active: A message reminds the agent it is in plan mode, can only read files and think, and should use
plan_exitto return to build mode. - Build switch: A message reminds the agent it has returned to build mode with full tool access.
- Max steps reached: When the step count hits the configured maximum, a message tells the agent to wrap up and provide a summary.
Mid-Loop User Messages
If the user sends a message while the agent is still processing (mid-loop), the message is wrapped in <system-reminder> tags and injected into the conversation. This allows the user to provide corrections or additional context without waiting for the agent to finish.
<system-reminder>
{user's message text}
</system-reminder>
Complete API Reference
All HTTP routes exposed by the OpenCode server.
| Method | Route | Description |
|---|---|---|
| GET | /session | List all sessions |
| POST | /session | Create a new session |
| GET | /session/:id | Get session by ID |
| DELETE | /session/:id | Delete a session |
| GET | /session/:id/message | List messages in a session |
| POST | /session/:id/message | Add a message to a session |
| POST | /session/:id/prompt | Submit a prompt (starts the agent loop) |
| POST | /session/:id/prompt_async | Submit a prompt asynchronously |
| POST | /session/:id/abort | Abort the running agent loop |
| POST | /session/:id/compact | Trigger manual compaction |
| POST | /session/:id/revert | Revert to a previous state (undo) |
| POST | /session/:id/fork | Fork a session at a given point |
| POST | /session/:id/share | Generate a shareable link for a session |
| GET | /session/status | Get session status information |
| GET | /event | SSE stream for session events |
| GET | /global | Global session data |
| GET | /global/event | Global SSE stream (all sessions) |
| GET | /global/health | Health check endpoint |
| GET | /global/sync-event | Sync event stream for workspace synchronization |
| GET | /project | Get project info |
| GET | /provider | List available providers |
| GET | /config | Get current configuration |
| PUT | /config | Update configuration |
| GET | /mcp | List MCP servers |
| POST | /mcp | Add an MCP server |
| DELETE | /mcp | Remove an MCP server |
| GET | /permission | List permission rules |
| POST | /permission | Add/update permission rules |
| GET | /question | Get pending permission questions |
| POST | /question | Answer a permission question |
| WS | /pty | WebSocket for pseudo-terminal access |
| GET | /file | Read a file |
| PUT | /auth/:providerID | Set credentials for a provider |
| DELETE | /auth/:providerID | Remove auth for a provider |
| GET | /agent | List available agents |
| GET | /skill | List available skills |
| GET | /lsp | Get LSP status and diagnostics |
| GET | /command | List available commands |
| GET | /path | Get path information |
| GET | /vcs | Get version control status |
| POST | /instance/dispose | Dispose the current server instance |
| GET | /log | Get server logs |
| GET | /formatter | Get formatter configuration |
| GET | /doc | Get documentation |
Event Bus Events
Events published on the internal event bus. These are the events that flow through SSE to connected clients.
| Event | Description |
|---|---|
session.created | A new session was created |
session.updated | Session metadata changed (title, status, model) |
session.deleted | A session was deleted |
session.compacted | A session was compacted (older messages summarized) |
message.updated | A message was updated (new parts, content changes) |
part.updated | A message part was updated |
part.delta | Incremental content added to a part (streaming text) |
permission.asked | A tool is requesting user permission |
permission.replied | User responded to a permission request |
lsp.updated | LSP diagnostics or status changed |
mcp.tools.changed | MCP server tools were added, removed, or changed |
server.connected | A client connected to the server |
server.heartbeat | Periodic heartbeat for connection keepalive |
server.instance.disposed | The server instance is shutting down |
Building a Clone Checklist
A practical implementation guide for building an OpenCode-like coding agent from scratch. Organized into phases, from core infrastructure to user interfaces.
Phase 1: Core Infrastructure
- Set up SQLite with Drizzle ORM. Configure WAL mode, busy timeout, and foreign keys.
- Implement event sourcing: define event types, write projectors, build
SyncEvent.run()with transactional consistency. - Build a configuration loader that merges project-level, global, and environment variable configs.
- Set up Effect services for dependency injection. Define service interfaces for database, filesystem, permissions, and providers.
- Implement the shadow git repo for filesystem snapshots (init, track, patch, restore, revert, diff).
Phase 2: Provider Integration
- Integrate the Vercel AI SDK. Set up
streamText()with the unified interface. - Build the provider registry: resolve provider SDK from model ID, handle API keys, base URLs.
- Import the model catalog from models.dev for context window sizes, pricing, and capability flags.
- Implement the transform layer for per-provider message normalization (Chapter 13).
- Add
wrapLanguageModelmiddleware for token tracking and event emission. - Handle provider-specific quirks: Anthropic cache headers, OpenAI Responses API, Gemini schema sanitization, Mistral tool call ID format.
Phase 3: Tool System
- Implement
Tool.define()with name, description, Zod schema, execute function, and permission level. - Build the permission system: Ruleset with glob matching, allow/ask/deny levels, serialization of tool args for matching.
- Implement core tools: read, write, edit (with the 9-strategy fuzzy matcher from Chapter 14), bash, glob, grep, apply_patch.
- Add the task tool for sub-agent spawning with parallel execution support.
- Build tool description loading from .txt template files with variable interpolation.
- Implement tool filtering per model (GPT gets apply_patch instead of edit/write).
Phase 4: Agent Loop
- Implement
SessionPrompt.loop(): while(true) that builds prompt, streams LLM, processes parts, executes tools, loops. - Build system prompt assembly: provider-specific base prompt selection, environment block, skills, user instructions, contextual instructions.
- Implement
SessionProcessor.create()for real-time stream processing and part creation. - Add doom loop detection (3 identical calls triggers permission ask).
- Implement step tracking with token usage accounting.
- Build the error handling and retry system (Chapter 15): exponential backoff, overflow detection, abandoned tool cleanup.
- Add auto-title generation via background LLM call.
Phase 5: Context Management
- Implement overflow detection with the
totalTokens >= inputLimit - reservedformula. - Build Phase 1 pruning: find old tool outputs past PRUNE_MINIMUM, replace with placeholder, protect recent PRUNE_PROTECT tokens.
- Build Phase 2 compaction: send older messages to the compaction agent, insert CompactionPart, implement
filterCompacted()for loading. - Use the token counting heuristic:
Math.round(string.length / 4).
Phase 6: Server Layer
- Set up Hono HTTP server with CORS, optional auth, gzip compression.
- Implement all API routes (Appendix C): session CRUD, message/prompt, events (SSE), config, permissions, MCP, auth, file, LSP, agents, skills, commands.
- Build SSE event streaming: session-scoped and global streams.
- Build the TypeScript SDK client with HTTP and in-process transports.
- Implement multi-instance support with port-per-project (hash of directory path).
Phase 7: User Interfaces
- Build the TUI with a reactive terminal framework (OpenTUI uses SolidJS). Implement the worker architecture with RPC bridge for server isolation.
- Implement the provider pattern: Theme, SDK (with 16ms event batching), Sync (mirrors server state locally), Dialog, Command, Keybind.
- Build the web app as a SolidJS + Tailwind application that connects to the server over HTTP + SSE.
- Wrap the web app in Tauri for the desktop app. Start server as child process, manage lifecycle.
- Add MCP support with stdio, HTTP, and SSE transports. Implement OAuth PKCE for remote servers. Namespace tools, subscribe to ToolListChangedNotification.
- Add LSP integration: auto-detect language servers, expose diagnostics/hover/definitions to the agent.
- Add extensibility: custom tools, custom agents (markdown + JSON), custom commands (markdown with YAML frontmatter), plugins with hooks.