OpenCode — Prompt Data Flow

What happens from the moment you press Enter to the response on screen

Tap any step to expand details ↓

Frontend (app)
SDK / HTTP
Server (opencode)
LLM / Tools
Response stream
⌨️
1 · User types & submits prompt
packages/app — PromptInput component

The user types in a contenteditable div. On Enter (or send button), handleKeyDown() fires. The DOM is parsed by parseFromDOM() into a typed Prompt object — extracting text, @-mentions, image attachments, and agent references.

Files

Key functions

handleInput() :869 handleKeyDown() :1121 parseFromDOM() :787
// prompt-input.tsx – simplified const handleKeyDown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() handleSubmit() // → submit.ts } }

Data produced

Prompt { text, parts[] } ImageAttachmentPart? FileAttachmentPart?
📦
2 · Context assembly & optimistic UI
packages/app — submit.ts

createPromptSubmit() calls buildRequestParts() to assemble all content parts. An optimistic message is inserted immediately into local state — this is why the user sees their message before any server round-trip. Then it calls sendFollowupDraft() which delegates to the SDK.

Files

Key functions

createPromptSubmit() :204 buildRequestParts() :107 sendFollowupDraft() :155
// submit.ts – simplified const parts = buildRequestParts(prompt, attachments) // show message immediately in UI dispatch({ type: "optimistic_message", parts }) // fire HTTP request client.session.promptAsync(sessionID, { parts })
RequestPart[] optimistic UserMessage
🌐
3 · HTTP POST to local server
packages/sdk → POST /api/session/:id/prompt

The typed SDK client (createOpencodeClient()) serialises the request and sends a POST to the local HTTP server running at a Unix socket / localhost. The header x-opencode-directory carries the workspace path so the server knows which project is active.

Files

Key functions

createOpencodeClient() :47 promptAsync() :90
// client.ts const res = await fetch( `/api/session/${sessionID}/prompt`, { method: "POST", headers: { "x-opencode-directory": cwd }, body: JSON.stringify({ parts }) } )
JSON body: { parts, delivery? } header: x-opencode-directory
🛤️
4 · Route handler & session lookup
packages/opencode — HTTP API routes

The opencode server validates the request schema, resolves the session by ID, and calls Session.prompt(). The route also applies permission settings attached to the request payload (e.g. "allow file writes without asking").

Files

Key functions

route POST :62 Session.prompt() :315
// session.ts (server) app.post("/api/session/:sessionID/prompt", async (c) => { const body = await c.req.json() const session = await getSession(c.req.param("sessionID")) return Session.prompt(session, body) })
Session object validated Prompt
💬
5 · User message persisted, loop starts
packages/opencode — session/prompt.ts

createUserMessage() stores the user turn in the SQLite database and emits a session.message.created event. Then runLoop() starts the agentic loop — it will keep running until the LLM returns with no pending tool calls.

Files

Key functions

prompt() :1210 createUserMessage() :1215 runLoop() :1239
// prompt.ts – simplified async function prompt(session, input) { const msg = await createUserMessage(session, input) emit("session.message.created", msg) await runLoop(session) // ← agentic loop }
UserMessage persisted in SQLite event: message.created
🤖
6 · LLM request prepared & streamed
packages/opencode — session/llm.ts + processor.ts

LLM.stream() calls LLMRequestPrep.prepare() to build the full messages array with system prompt, conversation history, and tool definitions. The AI SDK's streamText() then opens an SSE/streaming connection to the provider (Anthropic, OpenAI, etc.). Events flow back as a typed Stream<LLMEvent>.

Files

Key functions

LLM.stream() :53 LLM.run() :81 LLMRequestPrep.prepare() :106 streamText()
// llm.ts – simplified async function* stream(messages, tools, model) { const req = LLMRequestPrep.prepare(messages, tools) const result = await streamText({ model, messages: req.messages, tools: req.tools, }) for await (const delta of result.fullStream) { yield delta // → processor.ts } }
messages[] (full history) tool definitions[] Stream<LLMEvent>
🔧
7 · Tool calls detected & executed
packages/opencode — processor.ts (agentic loop)

The processor consumes each stream event. When a tool call arrives, it:
1. Persists the ToolPart to the database
2. Emits a live event so the UI shows "running tool…"
3. Executes the tool (read file, run shell cmd, search codebase…)
4. Appends the tool result and re-enters the loop — the LLM sees the result on the next turn. The loop continues until the LLM emits a final text response with no pending tool calls.

Files

Key functions

processor.create() :105 process() :52
// processor.ts – simplified for await (const event of llmStream) { if (event.type === "text-delta") appendTextPart(event.textDelta) else if (event.type === "tool-call") { persistToolPart(event) emit("tool.running", event.toolName) const result = await runTool(event) appendToolResult(result) continue // re-enter loop } }
ToolPart persisted tool result → next turn event: tool.running
📡
8 · Events streamed back to frontend
packages/app — global-sync.tsx + event-reducer.ts

The app maintains a persistent SSE event stream (globalSDK.event.start()). As the server emits events (session.message.updated, message.part.added, …), the event-reducer.ts merges them into the React context store — no polling needed. Text deltas update in real-time as the LLM streams tokens.

Files

Key functions

event.start() :402 applyDirectoryEvent() :93
// global-sync.tsx const stream = globalSDK.event.start() for await (const event of stream) { dispatch(applyDirectoryEvent(state, event)) } // UI re-renders automatically via React context
SSE event stream React context update text-delta → live tokens
🖥️
9 · Message rendered in timeline
packages/app — message-timeline.tsx

MessageTimeline reads sync.data.message[sessionID] from the store. It uses a virtualizer for performance (only renders visible messages). Each message is decomposed into parts: text (Markdown), tool calls with status badges, file diffs, reasoning blocks, and images. As the LLM streams, each text-delta appends tokens live — the user sees the response grow word-by-word.

Files

Rendered part types

TextPart (markdown) ToolPart (call + result) FilePart (diff) ReasoningPart ImagePart
// message-timeline.tsx const messages = sync.data.message[sessionID] return ( <Virtualizer items={messages}> {(msg) => msg.parts.map((part) => <MessagePart part={part} /> )} </Virtualizer> )
virtualized list live streaming tokens tool status badges