{"slug": "opencode-session-framework-internals", "title": "OpenCode Session Framework Internals", "summary": "OpenCode's session framework treats agent sessions as execution containers rather than chat transcripts, separating session identity from prompt execution and serializing work per session. The design exposes status as runtime state and uses events as the observation boundary, enabling clients to recover from long-running agents. This architecture is reflected in both the fleet client and the V2/core API, which transition from a working client protocol to a cleaner internal runtime model.", "body_md": "The easiest way to misunderstand an agent session is to treat it as a chat API call with a longer memory. Send a prompt, receive a response, append both to a transcript. That is enough for a demo. It is not enough for a coding agent.\n\nI ran into this while reading a small fleet client that drives remote OpenCode instances. The client does very little on the surface: create or reuse a session, send a prompt, wait for the remote agent to become idle, then fetch recent messages. The interesting part is that none of those verbs mean exactly what they mean in a normal request-response API. A prompt does not equal a response. A timeout does not mean failure. A session is not just a transcript. Status is not derived from the last line of text.\n\nThat small client is a useful entry point because it exposes the shape of the real system. OpenCode's session design is not one function that calls a model. It is a framework for admitting work, serializing execution, projecting durable state, streaming observations, and letting clients recover when a long-running agent is still in flight.\n\nPrimary code references:\n\n`opencode-fleet/src/tools.ts`\n\n`opencode-fleet/src/session.ts`\n\n`opencode-fleet/src/node.ts`\n\n`packages/opencode/src/server/routes/instance/httpapi/groups/session.ts`\n\n`packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts`\n\n`packages/opencode/src/session/session.ts`\n\n`packages/opencode/src/session/prompt.ts`\n\n`packages/opencode/src/session/run-state.ts`\n\n`packages/opencode/src/session/status.ts`\n\n`packages/opencode/src/session/processor.ts`\n\n`packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts`\n\n`packages/core/src/session.ts`\n\n`packages/core/src/session/input.ts`\n\n`packages/core/src/session/run-coordinator.ts`\n\n`packages/core/src/session/runner/llm.ts`\n\n`packages/core/src/event.ts`\n\n`packages/core/src/session/projector.ts`\n\nThe fleet client follows the desktop-compatible OpenCode API: `/session`\n\n, `/session/:id/prompt_async`\n\n, `/session/:id/message`\n\n, `/session/status`\n\n, and `/event`\n\n. The newer V2/core API exposes the same architectural direction more explicitly through `/api/session`\n\n, `/api/session/:id/prompt`\n\n, `/api/session/active`\n\n, `/api/session/:id/event`\n\n, and the durable `SessionInput`\n\nand `SessionEvent`\n\npipeline. Both matter because together they show the transition from a working client protocol to a cleaner internal runtime model.\n\n**If you are building an agent runtime, design sessions as execution containers, not as chat transcripts.** A real session framework has to preserve identity, admit work, run one continuation at a time, expose observable status, persist structured messages, and make interruption and recovery normal operations.\n\n**Separate session identity from prompt execution.** A session can outlive any single prompt. It owns directory, project, agent, model, title, permissions, messages, parts, and runtime state.\n\n**Treat prompt submission as admission.** The client should be able to submit work and return before the agent finishes. Completion is a separate observation problem.\n\n**Serialize execution per session.** Multiple prompts may arrive while the agent is busy. The runtime needs a coordinator that runs at most one drain loop per session and coalesces follow-up work.\n\n**Expose status as runtime state.** Busy, idle, and retry are not reliable if inferred from text. They should come from the execution layer or from an authoritative active-session set.\n\n**Use events as the observation boundary.** Clients should not poll messages to guess what happened. They should subscribe to session and message events, then keep a local projection.\n\n**Persist messages as structured state.** Text is only one part. Tool calls, tool results, reasoning, files, snapshots, errors, and step boundaries need identity and lifecycle.\n\n**Make timeout, interrupt, and reset distinct.** Timeout means the caller stopped waiting. Interrupt asks the runtime to stop work. Reset discards a client-side binding or context. These are different operations.\n\nThe fleet client is intentionally small. It exposes MCP tools such as `fleet_create_session`\n\n, `fleet_send_message`\n\n, `fleet_get_session_status`\n\n, `fleet_get_session_messages`\n\n, `fleet_interrupt_session`\n\n, and `fleet_reset_session`\n\n. Behind those tools, there are only two main classes.\n\n`SessionManager`\n\nkeeps an in-memory map from node name to active session ID. It lazily creates a session on first send, reuses the same session for future prompts, and recreates a session if the server returns `404`\n\n. `OpenCodeNode`\n\nwraps the remote HTTP API and owns a persistent SSE subscriber that listens to `/event`\n\n.\n\nThe important flow is short:\n\n``` php\nfleet_send_message\n-> SessionManager.send\n-> get or create session\n-> POST /session/:id/prompt_async\n-> wait for session.status idle over SSE\n-> GET /session/:id/message\n-> extract assistant text or tool progress summary\n```\n\nThat flow already contains several design choices worth copying.\n\nFirst, the client binds one long-lived session per remote node. It does not create a fresh session for every prompt. That preserves working context and makes follow-up prompts meaningful.\n\nSecond, sending a prompt is asynchronous. `OpenCodeNode.sendPromptAsync(...)`\n\nposts a user message to `/session/:id/prompt_async`\n\nand returns after the server accepts it. The fleet client then waits for status separately. This is the right split. If the same request both submits work and waits for the entire agent loop to finish, the client has no clean way to distinguish \"the server accepted my work but the agent is still running\" from \"the server never accepted my work.\"\n\nThird, timeout is not treated as failure. `SessionManager.send(...)`\n\ncatches `TimeoutError`\n\n, fetches partial messages, marks `timedOut: true`\n\n, and tells the caller the remote agent is likely still running. That is exactly the behavior an agent coordinator needs. In a coding-agent runtime, a slow task is often useful work, not a broken request.\n\nFourth, reset is guarded. `fleet_reset_session`\n\nchecks status and refuses to reset a busy session. This is not just user-interface caution. Resetting while a remote agent is running loses the caller's handle to in-flight work. The agent may still write files, ask for permissions, or finish with output after the caller has thrown away the session ID. A framework should make that hard to do accidentally.\n\nThe fleet implementation is not the whole OpenCode session architecture. It is a client-side adaptation. But it shows what the server must provide: stable session IDs, async prompt admission, observable status, message history, interruption, and enough structured message parts to explain progress before final text exists.\n\nIn the desktop-compatible API, the legacy route group defines `POST /session`\n\nas `session.create`\n\n. The handler eventually calls `Session.create(...)`\n\n, which creates a session record with an ID, slug, project, directory, path, optional workspace, title, agent, model, permissions, token counters, and timestamps. It publishes `session.created`\n\nthrough the event bridge. Projectors then write that session into SQLite.\n\nNothing has run yet.\n\nThat distinction is easy to miss. A session is not \"the model is working.\" A session is the durable container in which work may later happen. It represents a place in the filesystem, a selected agent and model, permission context, and a message history boundary.\n\nThe newer V2/core path makes the same idea clearer. `SessionV2.Service.create(...)`\n\nresolves the project for a location, creates a `Session.Info`\n\n, publishes a created event, and returns the stored session. Execution is not part of creation. The session starts idle.\n\nThis matters for agent framework design because the session ID becomes the join key for everything else:\n\nIf session creation also starts execution, that boundary gets muddy. If a session is only a transcript row, it will not have enough identity to support tooling, permissions, status, or recovery. A good session object should answer: where is this agent working, what policy applies, what model and agent should subsequent turns use, and which durable history does this execution belong to?\n\nThe desktop-compatible async endpoint is `/session/:sessionID/prompt_async`\n\n. Its handler requires the session, then forks `promptSvc.prompt(...)`\n\ninto the server scope and immediately returns `204 No Content`\n\n.\n\nThat means the HTTP response does not mean \"the assistant finished.\" It means \"the server accepted responsibility for starting the prompt work.\" The actual work continues in a fiber.\n\nInside `SessionPrompt.prompt(...)`\n\n, OpenCode creates a user message, stores its parts, touches the session, applies any per-prompt tool permission overrides, and then calls `loop(...)`\n\nunless the prompt was marked `noReply`\n\n. The loop is the real execution path.\n\nThe V2/core API names the same boundary more explicitly. `POST /api/session/:sessionID/prompt`\n\ncalls `SessionV2.Service.prompt(...)`\n\n. That service verifies the session, resolves the prompt, chooses a message ID, and calls `SessionInput.admit(...)`\n\n. Admission publishes `session.next.prompt.admitted`\n\nas a durable event. Only after the input is durably admitted does the service call `execution.wake(sessionID)`\n\n.\n\nThis is the key design move: prompt submission becomes durable input admission plus execution wakeup.\n\nThat gives the runtime several properties that a direct \"call the model now\" design does not have.\n\nThe prompt has an identity before the model runs. The system can reject duplicate message IDs. It can record that a prompt entered the session even if execution starts slightly later. It can choose not to resume immediately. It can queue or steer inputs. It can replay durable input history into a projected message stream. It can recover from client disconnects because the prompt is not merely an in-memory function argument.\n\nIf you are building an agent runtime, this is one of the most important principles to copy. Do not make the user's prompt disappear into a model call. Admit it into the session first. Then schedule execution.\n\nOnce prompts can be admitted independently from execution, the runtime needs a rule for what happens when work arrives while the session is already busy.\n\nOpenCode has two implementations that reveal the same idea.\n\nIn the desktop-compatible path, `SessionRunState`\n\nkeeps a per-session `Runner`\n\n. The runner has states such as `Idle`\n\n, `Running`\n\n, `Shell`\n\n, and `ShellThenRun`\n\n. `ensureRunning(...)`\n\nstarts work if idle. If a run is already active, it waits for that active run instead of starting a second one. If shell work is active, it can queue a run after the shell finishes. `cancel(...)`\n\ninterrupts the current fiber and returns the runner to idle.\n\nIn V2/core, `SessionRunCoordinator`\n\nis smaller and more explicit. It maintains a map from session ID to active entry. `wake(sessionID)`\n\nstarts a drain fiber if idle. If a fiber is already running, it sets `pendingWake = true`\n\n. When the active fiber settles successfully, the coordinator starts a successor if a wake was recorded. `interrupt(sessionID)`\n\nmarks the entry as stopping, clears pending wake, and interrupts the owner fiber.\n\nThat gives OpenCode an important invariant:\n\n``` php\none session -> at most one active drain loop\n```\n\nDifferent sessions can run concurrently. The same session cannot accidentally run two provider turns against the same history at the same time.\n\nThis is not an implementation detail. It is the difference between a predictable agent session and a race condition factory. Without a per-session coordinator, two prompts can read the same context, both call the model, both write assistant messages, and both execute tools against the filesystem. In a coding agent, that is dangerous. The second prompt may assume files are unchanged while the first prompt is editing them. Tool permissions and status become ambiguous. The UI cannot honestly say what the session is doing.\n\nThe right abstraction is not a mutex around the HTTP handler. It is a session execution coordinator. It should live at the runtime layer, below all clients, so desktop, TUI, MCP clients, scripts, and external tools all obey the same rule.\n\nThe execution loop itself is also larger than one model call.\n\nIn the desktop-compatible path, `SessionPrompt.runLoop(...)`\n\nrepeatedly sets the session busy, loads compacted history, finds the latest user and assistant state, handles subtasks and compaction tasks, resolves the current agent and model, builds tools, assembles system instructions, converts stored messages into provider messages, and calls `SessionProcessor.process(...)`\n\n. The processor consumes the provider stream and updates message parts as text, reasoning, tool calls, tool results, errors, and finish state arrive. If the model asked for tools, the loop continues so the tool results can be sent back to the model.\n\nIn V2/core, `SessionRunner.run(...)`\n\nfollows the same conceptual shape. It checks pending steer or queue inputs. `runTurnAttempt(...)`\n\npromotes pending input into active context, prepares system context, resolves model and tools, builds an `LLM.request(...)`\n\n, streams provider events, publishes structured session events, settles local tools, and continues if tool calls or new steering require another turn.\n\nThe naming matters. A good agent runtime does not have a `completeChat(...)`\n\nfunction. It has a drain loop. The loop drains admitted work until the session reaches a stable idle boundary.\n\nThat loop has to deal with continuation conditions:\n\nIf those conditions are bolted onto a single request handler, the handler becomes impossible to reason about. OpenCode keeps them inside session execution. Clients submit work, observe events, and interrupt if needed. They do not own the agent loop.\n\nThe fleet client originally has a tempting fallback: inspect messages and infer busy or idle by looking for step-finish parts after the last user message. That kind of fallback is useful for compatibility, but it should not be the primary status model.\n\nOpenCode's desktop-compatible runtime has `SessionStatus`\n\n. It keeps an instance-local map of non-idle sessions. `set(sessionID, { type: \"busy\" })`\n\npublishes a `session.status`\n\nevent and stores the status. `set(sessionID, { type: \"idle\" })`\n\npublishes both `session.status`\n\nand deprecated `session.idle`\n\n, then deletes the session from the map. A missing status means idle.\n\n`SessionRunState`\n\ncalls `status.set(...busy...)`\n\nwhen a runner becomes active and `status.set(...idle...)`\n\nwhen the runner returns to idle. `SessionProcessor`\n\nsets busy while processing provider streams and sets retry status during retry backoff. The server exposes the status map through `GET /session/status`\n\n, and it also streams `session.status`\n\nevents through `/event`\n\n.\n\nThe V2/core API exposes the same concept as `GET /api/session/active`\n\n. It returns the set of foreground drains currently owned by this OpenCode process. If a session appears there, it is running. If it is absent, it is inactive.\n\nThe lesson is simple: status should come from the execution owner.\n\nMessage history is a projection of what happened. It is not the authority for what is currently happening. A session may be busy before the first assistant step appears. A provider may be retrying without writing new visible text. A tool may be running with no final assistant answer yet. A streamed text delta may arrive before the durable final text part. If a client has to scrape messages to infer status, the runtime has failed to expose a basic operational fact.\n\nThis is why `opencode-fleet`\n\nkeeps a persistent SSE status stream and optimistically marks a session busy immediately after `prompt_async`\n\nreturns. There is a race window between prompt admission and the first SSE event. A client that immediately checks status should not conclude \"idle\" just because the event has not arrived yet.\n\nOpenCode clients do not need to keep asking, \"what changed?\" They subscribe.\n\nThe desktop-compatible `/event`\n\nendpoint registers an eager listener against `EventV2Bridge`\n\n, filters events by instance directory and workspace, emits a synthetic `server.connected`\n\n, sends heartbeat events, and streams JSON payloads as SSE. The global event endpoint wraps the `GlobalBus`\n\nand carries cross-instance events. The newer server package exposes `/api/event`\n\nfor all server events and `/api/session/:sessionID/event`\n\nfor durable per-session events.\n\nThat gives the desktop app and external clients a common observation model. The app's `server-session.ts`\n\napplies events into a local Solid store. It updates session info on `session.created`\n\nand `session.updated`\n\n, status on `session.status`\n\n, messages on `message.updated`\n\n, parts on `message.part.updated`\n\n, deltas on `message.part.delta`\n\n, permissions on `permission.asked`\n\n, questions on `question.asked`\n\n, and so on. It also reconciles optimistic local messages with confirmed server events.\n\nThis local projection is not just for UI polish. It is a fundamental architecture choice. The server owns truth. Clients maintain projections.\n\nThat separation solves several problems.\n\nIt lets a client show progress before a final response exists. It lets a client reconnect and refresh from durable history when needed. It keeps streaming deltas separate from final durable values. It lets different clients observe the same session without embedding execution logic in each client. It gives external tools a debugging path: subscribe to events, then inspect messages and parts when something looks wrong.\n\nIf you build an agent runtime without an event boundary, every client becomes a partial runtime. The UI will poll messages. The CLI will invent a different status heuristic. External tools will guess when work is done. Eventually those guesses disagree.\n\nThe most visible artifact of a session is the conversation. But OpenCode does not treat the conversation as plain text.\n\nIn the desktop-compatible projection, session messages live as message rows and part rows. A user message can contain text, files, agents, and subtasks. An assistant message can contain text, reasoning, tool parts, step markers, snapshots, patches, retries, compaction parts, and errors. A tool part has a call ID, tool name, input, status, output, metadata, attachments, and timing.\n\nThat shape is why `opencode-fleet`\n\ncan return useful partial output when a prompt times out. If the assistant has no text yet but tool calls are running, the client can summarize tool activity instead of returning an empty string. It can say the agent is busy and list the tools in progress.\n\nV2/core pushes this further with durable session events and projected messages. `SessionInput.admit(...)`\n\nrecords prompt admission. `SessionInput.promoteSteers(...)`\n\npublishes `session.next.prompted`\n\n. `createLLMEventPublisher(...)`\n\nconverts provider events into session events such as `session.next.step.started`\n\n, `session.next.text.delta`\n\n, `session.next.text.ended`\n\n, `session.next.tool.called`\n\n, `session.next.tool.success`\n\n, `session.next.tool.failed`\n\n, and `session.next.step.ended`\n\n. `SessionProjector`\n\nturns those events into queryable message rows.\n\nThis creates three useful layers:\n\n| Layer | Role |\n|---|---|\n| Durable events | What happened, in order, with session sequence numbers |\n| Projected messages | Query-friendly session state for UI and clients |\n| Client store | Local observable cache, including optimistic and streaming state |\n\nThat layering is more work than appending text to an array. It is also what makes a coding-agent session debuggable. If a tool failed, you can find the tool call. If a provider streamed text and then failed, you can represent partial output and final error separately. If a permission request blocked execution, it has identity. If compaction changed the context boundary, it is a session event and a message part, not an invisible truncation.\n\nThe design principle is that the transcript is a projection, not the source of truth. The source of truth is the session's structured event and message state.\n\nLong-running agents need lifecycle controls. OpenCode exposes several, and the differences matter.\n\n`prompt_async`\n\nstarts work and returns immediately. `session.status`\n\nor `/api/session/active`\n\ntells a client whether work is still running. `/session/:id/abort`\n\nor `/api/session/:id/interrupt`\n\nasks the runtime to stop active execution. Fetching messages shows what has happened so far. Resetting a client binding merely means the client stops using that session ID for future sends.\n\nThese operations should not be collapsed.\n\nThe fleet client handles this well. On timeout, it does not reset. It tells the caller the agent is still running and recommends checking status, inspecting messages, waiting, or interrupting. `fleet_interrupt_session`\n\nsends an abort signal but does not delete the session or clear the binding. `fleet_reset_session`\n\ndiscards the cached session ID only after checking that the session is not busy.\n\nThat behavior reflects the server-side reality. In the desktop-compatible runtime, `SessionRunState.cancel(...)`\n\ninterrupts active fibers and cancels related background jobs. The runner transitions back to idle and status events are emitted. In V2/core, `SessionRunCoordinator.interrupt(...)`\n\nmarks the active entry as stopping, clears pending wake, and interrupts the owner fiber. The runner then settles interrupted tools and assistant state.\n\nA reset cannot do that. Reset is a client-side context decision. Interrupt is an execution decision. Delete is a storage decision. Timeout is a waiting decision. If your framework uses one \"cancel\" or \"reset\" button for all four, users will eventually lose work or leave orphaned execution behind.\n\nOne subtle part of OpenCode's current codebase is that it has both the desktop-compatible instance API and the newer V2/core API mounted in the same process. The route tree in `packages/opencode/src/server/routes/instance/httpapi/server.ts`\n\nprovides legacy routes such as `/session/:id/prompt_async`\n\nand `/event`\n\n, while also mounting the newer `@opencode-ai/server`\n\nhandlers for `/api/session`\n\n, `/api/event`\n\n, and related endpoints.\n\nThat can look confusing if you read only endpoint names. It makes more sense if you separate protocol compatibility from runtime architecture.\n\nThe legacy API exists because clients depend on it. The desktop UI, generated SDKs, compatibility wrappers, CLI paths, and external tools still speak that language. It has concepts such as `promptAsync`\n\n, `message.part.delta`\n\n, and `session.status`\n\n.\n\nThe V2/core architecture makes the internal model more explicit. Prompt admission is a durable event. Pending inputs live in `SessionInputTable`\n\n. Execution is coordinated through `SessionExecution`\n\nand `SessionRunCoordinator`\n\n. Session events can be replayed per aggregate. Projectors build structured message rows from durable events.\n\nThe lesson for agent-runtime builders is not \"copy these exact endpoints.\" The lesson is to keep the compatibility shell thin. Let old clients keep their contract, but move the runtime toward clearer boundaries: admission, execution, events, projection, and observation.\n\nIf compatibility code owns the runtime model, every old endpoint shape becomes a permanent architectural constraint. If the runtime owns the model, compatibility handlers can translate.\n\nIf I were designing a session framework for a new coding agent, I would copy these pieces first.\n\nCreate sessions independently from prompts. A session should be a durable execution container with location, agent, model, permissions, title, timestamps, and identity. It should be useful before anything is running.\n\nAdmit prompts before running them. Give each prompt or user message an ID. Persist it or publish it durably. Only then wake execution. That makes retries, duplicate detection, queueing, and recovery possible.\n\nRun one drain loop per session. Do not let every HTTP request or client call start its own model execution. A coordinator should own session execution and serialize work for that session while allowing other sessions to run concurrently.\n\nMake status authoritative. Either expose a status map or an active execution set. Busy, idle, and retry should be runtime facts, not message-history guesses.\n\nStream events. Clients should subscribe to server events and maintain projections. Polling can exist as a fallback, but it should not be the core observation model.\n\nPersist structured message parts. Text alone is not enough. Tool calls, tool results, reasoning, files, errors, snapshots, and step boundaries need their own identities and states.\n\nDesign lifecycle controls separately. Timeout, wait, interrupt, reset, delete, and fork are not the same operation. Give them separate APIs and make dangerous transitions explicit.\n\nKeep compatibility outside the core. Endpoint names will change. SDK shapes will change. Desktop and CLI needs will differ. The runtime should be stable underneath those clients.\n\nThe simplest useful mental model for an agent session is this:\n\n``` php\nSession identity\n  -> admitted inputs\n  -> per-session execution coordinator\n  -> agent drain loop\n  -> structured events\n  -> projected messages\n  -> client-side observable state\n```\n\nThat shape is more complicated than a chat completion wrapper. But the complexity is paying for real product requirements: long-running work, tool execution, concurrent clients, interruption, retries, partial output, permissions, compaction, and debugging.\n\nThe mistake is to start with the provider API and build upward. Provider APIs know how to produce tokens and tool-call requests. They do not know what a session means in your product. They do not know how to serialize work per project directory. They do not know when a client timed out but the agent is still running. They do not know how your UI should reconcile optimistic messages with durable events. They do not know what it means to reset a remote worker safely.\n\nThe session framework owns those answers.\n\nOpenCode's implementation is valuable because it exposes that boundary. The model call is inside the session runtime, not the other way around. Prompts are admitted before execution. Execution is coordinated per session. Status is published by the runner. Events are the observation surface. Messages are structured projections. Clients can be thin because the runtime has a real shape.\n\nThat is the design principle worth taking: build the session as the agent's operating context. The chat transcript is only one view of it.\n\n*Thanks for reading. I build tools for AI coding agents at github.com/chncaesar:*", "url": "https://wpnews.pro/news/opencode-session-framework-internals", "canonical_source": "https://dev.to/antonio_zhu_e726fd856cd86/opencode-session-framework-internals-1oj8", "published_at": "2026-08-10 08:22:40+00:00", "updated_at": "2026-08-10 08:46:59.008354+00:00", "lang": "en", "topics": ["ai-agents", "ai-infrastructure", "developer-tools"], "entities": ["OpenCode"], "alternates": {"html": "https://wpnews.pro/news/opencode-session-framework-internals", "markdown": "https://wpnews.pro/news/opencode-session-framework-internals.md", "text": "https://wpnews.pro/news/opencode-session-framework-internals.txt", "jsonld": "https://wpnews.pro/news/opencode-session-framework-internals.jsonld"}}