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#

sequenceDiagram actor Browser participant Socket as ChatSocket participant Hub as runhub.Hub participant Prompt as prompt.Service participant Runtime as module.Runtime participant Provider as agent.Provider participant Prep as execution.Preparer participant Cmd as integration/agents/runtime participant CLI as Provider CLI participant Store as Chat repository Browser->>Socket: {type: "prompt", text, clientId?} Socket->>Prompt: Start(StartInput) Prompt->>Hub: StartRun(chatID, cancel) Hub-->>Browser: transient sync running=true Prompt->>Store: load metadata and prior events Prompt->>Store: persist user event Prompt->>Runtime: Lookup(selected provider ID) Prompt->>Provider: Run(ctx, RunRequest, emit) opt Project chat Provider->>Prep: Prepare(project + selected features) Prep-->>Provider: container target + project secrets Provider->>Cmd: BuildContainerCommand end Provider->>CLI: execute host process or lxc exec CLI-->>Provider: provider-native output Provider-->>Prompt: normalized agent.Event values Prompt->>Store: update session metadata and persist ChatEvent Prompt-->>Browser: broadcast sequenced ChatEvent Prompt->>Hub: FinishRun(chatID, runID) Hub-->>Browser: transient sync running=false

The concrete entry points are:

  1. ChatSocket.handle validates the chat and caller, subscribes to replay plus live events, and accepts prompt and cancel messages.
  2. prompt.Service.Start acquires the process-local, one-run-per-chat lock in runhub.Hub. A racing start returns ErrPromptAlreadyRunning; clientId acknowledgements are connection-local and are not persisted.
  3. prompt.Service.runPromptAs resolves chat state, prepares a provider-neutral request, looks up the registered runtime, and calls Provider.Run.
  4. The provider launches and parses its own CLI. It emits agent.Event values without importing chat, transport, store, or frontend types.
  5. emitAgentEvent persists 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.

FieldSource and meaning
ProviderNormalized chat provider ID. Chat creation/update and the prompt boundary both enforce module membership and host/project scope.
ConversationIDRemote chat ID, used to correlate events and logs. It is not the provider-native session ID.
PromptCurrent prompt after optional visible-history recovery and provider-specific skill triggers are added.
CwdLive tmux working directory when available, otherwise stored chat cwd, then host home as a fallback.
ModelSaved model ID. The adapter must validate or safely pass it as one process/protocol argument.
ModeProvider-neutral default or plan; the adapter translates it to native flags or protocol settings.
ResumeIDSession ID stored for the selected provider, but only when the descriptor declares resume support.
ForkForkPending, but only when the descriptor declares native fork support.
ProjectIDEmpty for a loose host chat; otherwise identifies the workspace container.
PreferencesSaved reasoning effort, service tier, approval policy, and sandbox policy. Adapters decide which supported values to forward.
EnableBrowserTrue only when the selected browser skill and the module's BrowserTools declaration both permit it.
EnableScheduleToolsTrue only for a project run when scheduled tools are declared and selected or the turn itself is scheduled.
RuntimeEnvShort-lived backend-issued schedule API URL and grant. Invalid environment names are discarded and these values override same-named project secrets.
InteractionResponsesRun-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-command prefixes slash-style /skill-name triggers;
  • dollar-mention adds $skill-name instructions;
  • instructions points at the selected SKILL.md files;
  • none leaves 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:

  1. Resolve the project through agent.ProjectResolver and reject a missing container name.
  2. Call ProjectResolver.Start even when stored state says running. This reconciles a container deleted or replaced outside the process.
  3. Validate that provisioning.ContainerDependencies is either completely wired or completely zero. Production supplies the complete set.
  4. 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.
  5. 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:

ProviderCredentialsSkill-link failureBrowser assetsBrowser MCP/core
ClaudeSeed from its profileBest effortBest effortRequired when Browser is enabled
CodexReject host API-key auth, then seedFatalBest effortRequired when Browser is enabled
MiniMaxRequire the host-managed Token Plan subscription key before preparationFatalBest effortRequired when Browser is enabled
KimiSeed/synchronize its dynamic directoryBest effortBest effortNot used
AntigravityNoneBest effortNot usedNot 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.go
  • codex/command.go
  • minimax/command.go
  • kimi/command.go
  • antigravity/command.go

The shared orchestration and command construction are in:

  • service/agent/execution/preparer.go
  • integration/agents/runtime/container_command.go

Runtime and parser contracts#

agent.Provider deliberately has only three responsibilities:

go
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 eventPersisted chat eventRequired fields or behavior
session.updatedsessionSet SessionID; provider defaults to the selected provider when omitted. The ID is also saved in chat metadata.
systemsystemUse Subtype and optional JSON Data; Message is not projected for system events.
assistant.deltaassistant_textPut only newly produced text in Text.
reasoning.deltathinkingPut only newly produced reasoning text in Text.
tool.startedtool_use_startSet stable ItemID, ToolName, and JSON Input.
tool.completedtool_use_endReuse ItemID; set Output and IsError.
interaction.requestinteraction_requestRetain the original request ID, method, input, native IDs, and interaction kind until the UI or server resolves it.
interaction.resolvedinteraction_resolvedReuse the interaction ID and preserve resolution status without persisting secret response values.
turn.status or run.interruptedturn_statusPreserve waiting/active/terminal status; interruption must not become successful completion.
collaborationcollaborationPreserve provider-native subagent identity, state, and final messages.
provider.nativeprovider_eventPersist and render a generic fallback for valid provider activity without a typed projection.
usage.updatedusage_updatePublish the provider's latest normalized running usage.
run.completedcompletePut normalized token data in Usage when the provider supplies it.
run.failed or errorerrorPut 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_id to session.updated;
  • stream_event text deltas to assistant.delta;
  • assistant thinking and tool_use blocks to reasoning/tool-start events;
  • user tool_result blocks to tool completion;
  • system records to normalized system events;
  • result to run.completed, or to run.failed when is_error is 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:

  1. send initialize with Remote client information and experimental API support;
  2. after its response, send initialized and thread/start, thread/resume, or thread/fork;
  3. require a non-empty returned thread ID and model, emit session.updated for a new/different thread, then send turn/start;
  4. retain the returned native turn ID, then select concurrently over App Server output, user interaction responses, and Remote cancellation;
  5. consume notifications until turn/completed emits run.completed, run.failed, or run.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;
  • Fork is invalid unless Resume is 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.updated event clears ForkPending and 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 cancel path 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/error is already persisted through the normal event projection. Return agent.ErrRunFailed to avoid duplicating it.
  • run.completed and provider error events are persisted. Run-hub sync state 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 ErrSessionNotFound mapping;
  • 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) in factory.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:

bash
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 ./...
remote.futrx documentation