Runtime and event parsing#
This chapter follows one prompt from the chat WebSocket to a provider CLI and back into Remote's persisted event stream. Provider packages own CLI syntax and wire-protocol parsing. The service layer sees only the contracts in internal/agent.
End-to-end run path#
The concrete entry points are:
ChatSocket.handlevalidates the chat and caller, subscribes to replay plus live events, and acceptspromptandcancelmessages.prompt.Service.Startacquires the process-local, one-run-per-chat lock inrunhub.Hub. A racing start returnsErrPromptAlreadyRunning;clientIdacknowledgements are connection-local and are not persisted.prompt.Service.runPromptAsresolves chat state, prepares a provider-neutral request, looks up the registered runtime, and callsProvider.Run.- The provider launches and parses its own CLI. It emits
agent.Eventvalues without importing chat, transport, store, or frontend types. emitAgentEventpersists session identity, projects recognized agent events into chat events, and sends them through the run hub. The hub appends them to the store before broadcasting them.
The run lock, subscriptions, and cancellation state are backend-memory state. They disappear on restart, and Remote does not reattach to a provider process that survives the backend.
How RunRequest is built#
agent.RunRequest is the only run input a provider receives.
| Field | Source and meaning |
|---|---|
Provider | Normalized chat provider ID. Chat creation/update and the prompt boundary both enforce module membership and host/project scope. |
ConversationID | Remote chat ID, used to correlate events and logs. It is not the provider-native session ID. |
Prompt | Current prompt after optional visible-history recovery and provider-specific skill triggers are added. |
Cwd | Live tmux working directory when available, otherwise stored chat cwd, then host home as a fallback. |
Model | Saved model ID. The adapter must validate or safely pass it as one process/protocol argument. |
Mode | Provider-neutral default or plan; the adapter translates it to native flags or protocol settings. |
ResumeID | Session ID stored for the selected provider, but only when the descriptor declares resume support. |
Fork | ForkPending, but only when the descriptor declares native fork support. |
ProjectID | Empty for a loose host chat; otherwise identifies the workspace container. |
Preferences | Saved reasoning effort, service tier, approval policy, and sandbox policy. Adapters decide which supported values to forward. |
EnableBrowser | True only when the selected browser skill and the module's BrowserTools declaration both permit it. |
EnableScheduleTools | True only for a project run when scheduled tools are declared and selected or the turn itself is scheduled. |
RuntimeEnv | Short-lived backend-issued schedule API URL and grant. Invalid environment names are discarded and these values override same-named project secrets. |
InteractionResponses | Run-scoped channel carrying explicit UI responses to provider-initiated requests. Providers without interactive protocols may ignore it. |
If no native session is available, the prompt service prepends visible user and assistant_text history. The transcript is bounded to the last 24,000 bytes. Tool output and reasoning are not copied into that transcript. See visibleTranscript and promptWithVisibleHistory.
Selected skills are translated from the module's declared strategy by promptWithSelectedSkills:
slash-commandprefixes slash-style/skill-nametriggers;dollar-mentionadds$skill-nameinstructions;instructionspoints at the selectedSKILL.mdfiles;noneleaves the prompt unchanged.
Host and project execution#
Every built-in provider has a provider-local command builder. A factory and profile declare policy, but they do not automatically implement the provider's native run command. A new adapter must translate RunRequest; for project execution it receives the shared agent.ProjectPreparer port constructed by module.Factory through service/agent/execution.
For a loose chat, built-ins execute the provider binary directly on the host with the requested working directory and a filtered/augmented environment.
For a project chat, execution.Preparer owns this common workflow:
- Resolve the project through
agent.ProjectResolverand reject a missing container name. - Call
ProjectResolver.Starteven when stored state saysrunning. This reconciles a container deleted or replaced outside the process. - Validate that
provisioning.ContainerDependenciesis either completely wired or completely zero. Production supplies the complete set. - Use the exact validated provider profile to ensure the CLI and, where supported, credentials. Publish shared instructions, the selected profile's non-secret runtime assets, and skill compatibility links; apply the provider factory's browser policy; provision scheduled tooling when requested; enable LXD boot autostart.
- Load project secrets. Failure is currently best-effort and yields an empty secret list.
Before reconciliation the preparer emits system/container_starting only when the stored project status is not running; it still calls Start on every preparation. It emits system/container_preparing only when a nonzero set of container ports enters the provisioning phase.
The preparer returns only the stable project/container target and secrets. The adapter then calls runtime.BuildContainerCommand to assemble lxc exec, normally from /workspace. Provider-owned fixed environment entries come first, then project secrets, provider-forced entries, and finally sorted backend runtime variables. Runtime keys mask same-named secrets even when the runtime key is later discarded as invalid. The adapter still owns its binary, arguments, stdin or positional prompt, and execution protocol.
After a successful run, Claude, Codex, and Kimi make a best-effort credential sync from the container. Its application-wide timeout currently defaults to 30 seconds through config.AgentOptions; Antigravity has no credential sync contract.
Factories express only real policy differences:
| Provider | Credentials | Skill-link failure | Browser assets | Browser MCP/core |
|---|---|---|---|---|
| Claude | Seed from its profile | Best effort | Best effort | Required when Browser is enabled |
| Codex | Reject host API-key auth, then seed | Fatal | Best effort | Required when Browser is enabled |
| MiniMax | Require the host-managed Token Plan subscription key before preparation | Fatal | Best effort | Required when Browser is enabled |
| Kimi | Seed/synchronize its dynamic directory | Best effort | Best effort | Not used |
| Antigravity | None | Best effort | Not used | Not used |
Failures to list project secrets are currently ignored and the run continues without them. Backend-issued runtime variables win over project secrets with the same key. Codex additionally removes OPENAI_API_KEY and rejects a host auth record explicitly marked as API-key authentication. It does not currently pre-inspect a newer project-local auth record before launch. Its intended flow is ChatGPT subscription authentication; see Authentication and access for the current readiness-check limitation.
The provider-specific implementations are in:
claude/command.gocodex/command.gominimax/command.gokimi/command.goantigravity/command.go
The shared orchestration and command construction are in:
service/agent/execution/preparer.gointegration/agents/runtime/container_command.go
Runtime and parser contracts#
agent.Provider deliberately has only three responsibilities:
type Provider interface {
ID() ProviderID
Capabilities(context.Context, CapabilityRequest) (Capabilities, error)
Run(context.Context, RunRequest, func(Event)) error
}Parser(req) is not part of agent.Provider. Structured-stream adapters may implement agent.LineParser and use runtime.RunProcess, but a provider may own a different protocol loop.
RunProcess scans non-empty stdout lines, logs and skips individual parse errors, emits every parsed event, and stops accepting additional stderr lines for its ProcessError capture after the buffer reaches 64 KiB. Its default stdout line limit is 16 MiB and stderr line limit is 1 MiB. Cancellation is treated as a normal stop and returns nil. If the parser emitted run.failed and the process then exits non-zero, it returns agent.ErrRunFailed; otherwise a non-zero exit is returned as a process error.
An adapter must ensure that a successful native run produces a normalized run.completed event. The shared line runner does not synthesize one merely because a process exits zero. An adapter that already emitted run.failed should return an error wrapping agent.ErrRunFailed; this prevents the prompt service from appending a second generic <provider> exit error.
Normalized agent events#
The provider boundary uses agent.Event. The prompt projection currently recognizes the following types:
| Agent event | Persisted chat event | Required fields or behavior |
|---|---|---|
session.updated | session | Set SessionID; provider defaults to the selected provider when omitted. The ID is also saved in chat metadata. |
system | system | Use Subtype and optional JSON Data; Message is not projected for system events. |
assistant.delta | assistant_text | Put only newly produced text in Text. |
reasoning.delta | thinking | Put only newly produced reasoning text in Text. |
tool.started | tool_use_start | Set stable ItemID, ToolName, and JSON Input. |
tool.completed | tool_use_end | Reuse ItemID; set Output and IsError. |
interaction.request | interaction_request | Retain the original request ID, method, input, native IDs, and interaction kind until the UI or server resolves it. |
interaction.resolved | interaction_resolved | Reuse the interaction ID and preserve resolution status without persisting secret response values. |
turn.status or run.interrupted | turn_status | Preserve waiting/active/terminal status; interruption must not become successful completion. |
collaboration | collaboration | Preserve provider-native subagent identity, state, and final messages. |
provider.native | provider_event | Persist and render a generic fallback for valid provider activity without a typed projection. |
usage.updated | usage_update | Publish the provider's latest normalized running usage. |
run.completed | complete | Put normalized token data in Usage when the provider supplies it. |
run.failed or error | error | Put safe user-facing text in Message. |
run.started exists in the agent enum but is not projected by chatEventFromAgentEvent, so current adapters must not rely on it for user-visible state. agent.Event.Raw remains diagnostic-only. A provider that needs native data to survive persistence must put a bounded, safe payload and its correlation IDs in agent.Event.Native.
Provider parsing behavior#
Claude#
claude.Provider.Run launches claude -p --output-format stream-json --include-partial-messages --verbose and uses claude.Parser through runtime.RunProcess.
The parser maps:
- a changed
session_idtosession.updated; stream_eventtext deltas toassistant.delta;- assistant
thinkingandtool_useblocks to reasoning/tool-start events; - user
tool_resultblocks to tool completion; systemrecords to normalized system events;resulttorun.completed, or torun.failedwhenis_erroris true.
Malformed top-level JSON is returned to RunProcess, logged, and skipped. Malformed nested messages are ignored after any session event already derived from that line.
Codex and MiniMax: production app-server path#
Production Codex or MiniMax runs do not use codex/parser.go. codex.Provider.Run starts one fresh codex app-server process per turn and delegates to codexharness.Run. minimax.Provider.Run uses the same neutral harness with MiniMax's provider ID and label, while its own adapter retains the endpoint, key environment variable, model catalog, and isolated CODEX_HOME. The process is ephemeral, while a persisted thread can still be resumed or forked.
The normal JSON-RPC sequence is:
- send
initializewith Remote client information and experimental API support; - after its response, send
initializedandthread/start,thread/resume, orthread/fork; - require a non-empty returned thread ID and model, emit
session.updatedfor a new/different thread, then sendturn/start; - retain the returned native turn ID, then select concurrently over App Server output, user interaction responses, and Remote cancellation;
- consume notifications until
turn/completedemitsrun.completed,run.failed, orrun.interrupted; close stdin after that authoritative terminal notification.
appServerEventParser maps agent/plan deltas, reasoning deltas, command execution, file changes, MCP and dynamic tools, collaboration tools, web search, last-turn token usage, and terminal turn state. It retains a versioned native envelope on typed events and emits provider.native for unknown valid methods or item types. It tracks text already emitted for each item so completed whole-text snapshots contribute only the missing suffix.
appServerRequestHandler keeps every server-to-client request pending by its original string or numeric JSON-RPC ID. The request is persisted and rendered as an interaction card. The browser sends an explicit interaction_response WebSocket message, the prompt service routes it to the active run, and the handler answers the same App Server request. Remote does not infer a decision from Default or Plan mode.
Cancellation also remains inside the native lifecycle. The provider keeps the process alive after the Remote context is cancelled, the harness sends turn/interrupt once it knows both native IDs, and it waits for turn/completed(status=interrupted). Process termination is reserved for protocol failure or interrupt timeout.
A failed thread/resume or thread/fork whose message contains not found or no rollout becomes agent.ErrSessionNotFound. Other protocol errors are wrapped in runtime.ProcessError with captured stderr.
See Codex App Server architecture for the complete component, interaction, persistence, cancellation, subagent, and capability-discovery flows.
codex/parser.go parses the older codex exec --json JSONL shape and remains covered by focused tests, but it is currently a compatibility parser rather than the production run path. Do not use it as the reference when changing app-server event handling.
Kimi#
kimi.Provider.Run runs kimi -p <prompt> --output-format stream-json through RunProcess and parses it with kimi.Parser.
Kimi's OpenAI-chat-shaped JSONL maps assistant content and tool calls, tool results, and the final role=meta,type=session.resume_hint record. That final record supplies a changed session ID and is also Kimi's de-facto run.completed; the CLI does not provide a separate completion, reasoning, or usage line. Because Kimi has no native fork primitive, Run clears ResumeID when Fork is true.
Antigravity#
Antigravity print mode is unstructured. Production antigravity.Provider.Run therefore bypasses RunProcess and streams raw stdout chunks as assistant deltas so blank lines and Markdown paragraphs survive. It captures a 4 KiB combined output tail for errors, maps sign-in-looking failures to a focused instruction, and emits completion itself.
Because print mode does not report a new conversation ID, the adapter snapshots the provider's brain directory before and after a fresh run. Exactly one new, valid directory becomes session.updated; zero or multiple candidates are treated as ambiguous and no session is saved. See antigravity/session.go. Its parser.go is a line-oriented test/helper parser, not the production chunk-streaming path. Antigravity and Kimi both clear resume state for requested forks because their descriptors do not declare native fork support.
Sessions, forks, and recovery#
Sessions are stored in the provider-keyed Meta.Sessions map. The four named session fields remain only as compatibility mirrors for older records and clients. A new provider needs no new storage field. See chat.Meta.NormalizeSessions and filechat records.
The module descriptor controls orchestration:
- without
Resume, the prompt service never passes the saved ID; Forkis invalid unlessResumeis also true;- chat forking preserves and marks a provider session only when native fork is declared; otherwise the copied chat starts fresh from visible history;
- a
session.updatedevent clearsForkPendingand may populate an empty chat model from the provider's reported model.
Any adapter may return agent.ErrSessionNotFound. When it does so during a resume, the prompt service clears only that provider's saved session, clears the pending fork, persists a system/session_recovered event, rebuilds a bounded visible transcript, and retries the turn once without ResumeID.
Failure and cancellation rules#
- The WebSocket
cancelpath calls the run's context cancellation. Providers must launch children with that context; the in-memory run lock is released when the run goroutine returns. - A cancellation is not persisted as an error by the shared process runner.
- Parser errors for individual structured lines are logged and skipped so one malformed provider message does not discard the rest of the turn.
- If a provider returns an error other than
agent.ErrRunFailed, the prompt service persists<provider> exit: <error>. - A provider-emitted
run.failed/erroris already persisted through the normal event projection. Returnagent.ErrRunFailedto avoid duplicating it. run.completedand provider error events are persisted. Run-hubsyncstate and prompt accepted/rejected acknowledgements are transient.
Tests to add for a run adapter#
In the concrete provider package, pin the behavior the adapter owns:
- exact Default and Plan command/protocol translation;
- model, effort, service-tier, resume, fork, browser, and schedule behavior the descriptor claims;
- provider preparation-policy declarations;
- provider-native host/project arguments and environment entries;
- session discovery and
ErrSessionNotFoundmapping; - text/reasoning deltas, tool start/end correlation, completion with usage, and failure parsing;
- malformed/unknown native records and cancellation;
- compile-time
var _ agent.Provider = (*Provider)(nil)infactory.go.
Keep shared workflow tests in their owning packages: project reconciliation, provisioning order, error text, and best-effort branches belong under internal/service/agent/execution; common container command and environment precedence belong under internal/integration/agents/runtime; factory projection and validation belong under internal/service/agent/module.
Run focused tests first, then from backend/ run:
go test ./internal/integration/agents/<id> ./internal/integration/agents/runtime ./internal/service/agent/execution ./internal/service/agent/module ./internal/service/prompt
go test -race ./internal/integration/agents/<id> ./internal/integration/agents/runtime ./internal/service/agent/execution ./internal/service/prompt
go test ./...
go vet ./...