Concepts · Hosted execution
Runs and events
A conversation turn is durable conversation work. A hosted run is the process-local coordination envelope that lets requests, subscribers, approvals, and cancellation address that work while it is active.
Customization depth
Depth 4 — lifecycle coordination above the conversation engine
Hosting depth
Long-lived Node.js process; optional browser-safe remote consumer
Documentation status
Canonical reference
Assumptions
- One ConversationRunService instance remains alive for the active run and its replay-retention window.
- The host can construct a trusted address containing at least a sessionId and an authorization scope.
- Remote payloads are projected through explicit public schemas before crossing the transport boundary.
- Process-local run identity, one active run per address, ordered sequence numbers, terminal settlement, cancellation, approval resolution, and bounded replay.
- Transport-neutral run consumer cursor correctness, duplicate suppression, gap detection, terminal detection, and bounded reconnect calculation.
- Optional Node SSE framing and browser fetch/SSE parsing when the HTTP/SSE entrypoints are selected.
- Run-service lifetime, authenticated address scope, start/subscribe/cancel authorization, transport routes, CORS, limits, audit, and public errors.
- Public activity and result projection, client cursor persistence, actual retry timers, UI state, and product-specific terminal presentation.
- Multi-process routing, restart recovery, draining, durable delivery, and broker infrastructure when deployment topology requires them.
Turn versus run
The conversation engine can execute a turn directly in one process:
await engine.turns.submit({ sessionId, prompt, host })That is enough when the caller stays attached until completion. Add a ConversationRunService when a turn must be addressable independently from one request—for example, when an HTTP request returns immediately, an SSE subscriber attaches later, a user can cancel from another request, or an approval waits for a separate UI action.
import { createConversationEngine } from '@roackb2/heddle'
import { ConversationRunService } from '@roackb2/heddle/hosted'
const engine = createConversationEngine({ workspaceRoot, stateRoot, model })
const runs = new ConversationRunService({
replay: { maxEventsPerRun: 512, retentionMs: 300_000 },
})The run service does not replace the engine. The engine remains the durable owner of session and turn semantics; the run service coordinates active work in one host process.
Address first, run ID second
Every run has two identities:
- the address is host-defined scope and must contain at least
sessionId; - the run ID is generated by Heddle for one accepted execution.
The default address is { scopeId, sessionId }. A product can define a richer address and stable key:
type ProductRunAddress = {
accountId: string
workspaceId: string
sessionId: string
}
const runs = new ConversationRunService<ProductRunAddress>({
addressKey: ({ accountId, workspaceId, sessionId }) =>
JSON.stringify([accountId, workspaceId, sessionId]),
})Only one active run may occupy one address key. A conflicting start fails instead of silently launching two writers against the same conversation.
Possession of a runId is never authorization. Authenticate the caller, derive the trusted address, and then ask the service to resolve, subscribe, cancel, or approve within that address.
Start and acceptance
Use startTurn for a new prompt and startContinue for continuation semantics:
const run = runs.startTurn({
address: { accountId, workspaceId, sessionId },
engine,
turn: { sessionId, prompt },
projectResult(result) {
return {
outcome: result.outcome,
summary: result.summary,
}
},
projectError(error) {
return { code: 'agent_run_failed', message: safePublicMessage(error) }
},
})The returned handle includes runId, acceptedAt, a result promise, an async events(...) stream, cancel(), and resolveApproval(...).
Use an acceptance hook when the host must durably record or audit the accepted run before execution starts. The async startAndWaitForAcceptance path does not report acceptance until that hook succeeds.
Canonical event envelope
Every published item has the same envelope:
{
runId: string
sequence: number
timestamp: string
kind: 'activity' | 'result' | 'cancelled' | 'error'
}The payload depends on kind:
activitycarries one semanticConversationActivity;resultcarries the final result or the host's projected result;cancelledcarries a reason;errorcarries a public{ code, message }projection.
Sequence numbers begin at 1 and increase once per run. A run publishes many activity events but exactly one terminal event: result, cancelled, or error. After terminal settlement, subscribers close and later events are invalid.
Use HeddleEventType for activity comparisons instead of copying string literals such as assistant.stream, tool.calling, tool.completed, tool.approval_requested, or compaction.running.
Replay and reconnect
A subscriber reconnects with the last accepted sequence:
for await (const event of runs.subscribe({
address,
runId,
afterSequence: 42,
signal,
})) {
sendToClient(event)
}The service first replays retained items whose sequence is greater than 42, then continues with live delivery. Disconnecting a subscriber removes that subscriber; it does not cancel the run.
Replay is intentionally bounded by both event count and retention time. If the requested cursor is older than the retained window, Heddle throws ConversationRunReplayUnavailableError. The host must present a recovery path—usually reload durable conversation state—rather than pretending the missing live interval was delivered.
The replay buffer is process-local. It does not survive host restart and does not provide cross-instance delivery. Durable final conversation state remains in the engine's session repository.
Cancellation wins terminal settlement
Cancellation aborts the run controller and denies any pending approval. For startTurn and startContinue, a late execution result is not allowed to overwrite an already requested cancellation: the stream settles as cancelled.
Cancellation is explicit product behavior, not subscriber cleanup. Closing an SSE connection or navigating away should abort only that subscription. A separate authorized cancel operation should call cancelRun(address, runId) or the handle's cancel().
Approval lifecycle
When a gated tool call needs a decision, the run service can retain the pending approval for the same address and run. Another authorized request can resolve it through the run handle or resolvePendingApproval.
The service owns the wait and resolution plumbing. The product owns who is an authenticated approver, which actions policy permits, the approval UI, and whether a decision can be remembered.
Public result and error projection
Internal turn results can contain tool inputs, paths, traces, and artifact metadata that should not cross a remote boundary. Use projectResult and projectError before the terminal event is published.
For remote clients, add a ConversationRunProtocolCodec with synchronous Standard Schema validators. The validator output is the public allowlist. The codec validates run ID, positive sequence, timestamp, terminal vocabulary, host payloads, and JSON safety before parsing or serialization.
import { z } from 'zod'
import { ConversationRunProtocolCodec } from '@roackb2/heddle-remote'
const protocol = new ConversationRunProtocolCodec({
activity: z.object({ type: z.string().min(1) }),
result: z.object({ outcome: z.string(), summary: z.string() }),
})The codec cannot decide authorization or discover secrets for you. Strict public schemas and host-side policy remain mandatory.
Remote consumer invariants
ConversationRunConsumerService is a transport-neutral client state machine. For one selected run it:
- supplies the canonical
afterSequencecursor; - ignores events for another run;
- ignores already accepted duplicate sequences;
- throws when a sequence gap is observed;
- advances the cursor only after acceptance;
- recognizes all three terminal kinds;
- rejects events received after terminal;
- calculates bounded reconnect attempts and resets the retry budget after progress.
The product still owns the subscription handle, actual timer, online/offline policy, cursor persistence, retry UI, and rendering.
HTTP/SSE is an optional assumption layer
On the server, @roackb2/heddle/hosted/http-sse provides strict replay-cursor parsing and SSE streaming over Node IncomingMessage and ServerResponse. It owns canonical frames, backpressure, and subscriber-only disconnect cleanup. It does not register routes or choose auth, CORS, rate limits, validation, or JSON error policy.
In a browser, @roackb2/heddle-remote/http-sse adds the conventional REST run resource, authenticated fetch, response validation, incremental SSE parsing, and event-identity checks. It does not own React, retry timers, auth policy, or UI state.
Deployment implications
One process must be able to route start, subscribe, cancel, and approval operations to the ConversationRunService that owns the active run. In a single-process deployment this is direct. In a multi-instance deployment, add sticky/shared routing or a broker only when required by your topology.
Do not describe the process-local replay buffer as durable delivery. If restart recovery or cross-region continuation is a product requirement, design that infrastructure explicitly and keep the Heddle session repository as the durable conversation source of truth.
Invariants to verify
- A second start for the same address conflicts while the first run is active.
- Disconnecting one subscriber never cancels the underlying run.
- Reconnecting after sequence N neither restarts execution nor duplicates accepted events.
- A sequence gap fails visibly instead of advancing the cursor.
- A different authenticated scope cannot subscribe, cancel, or resolve approval by guessing a run ID.
- Explicit cancellation produces a terminal cancelled event even when execution finishes at nearly the same time.
- Exactly one terminal event is accepted, and no later event mutates client state.
- Only explicitly projected public activity, result, and error fields cross the transport boundary.