Use the remote-run layer when a client observes a Heddle conversation through a transport and may disconnect or reconnect while the run continues.
The public layers are intentionally separate:
import { createConversationEngine } from '@roackb2/heddle'
import { ConversationRunService } from '@roackb2/heddle/hosted'
import {
ConversationRunConsumerService,
ConversationRunProtocolCodec,
} from '@roackb2/heddle-remote'
import { ConversationRunHttpSseClient } from '@roackb2/heddle-remote/http-sse'@roackb2/heddleowns persisted conversation semantics.@roackb2/heddle/hostedowns process-local active-run coordination.@roackb2/heddle-remoteis independently installable and owns client cursor correctness plus runtime wire validation without choosing a transport.@roackb2/heddle-remote/http-sseis an optional assumption layer for the conventional REST run resource and streamingfetch; it still does not choose React or auth policy.
Heddle owns the run envelope and terminal vocabulary. The host must explicitly choose which activity and result fields are safe for remote clients. Payload validators use the validator-neutral Standard Schema interface; Zod 3.24+, Zod 4, Valibot, ArkType, and other compatible validators work without adapters.
import { z } from 'zod'
import { ConversationRunProtocolCodec } from '@roackb2/heddle-remote'
const PublicActivitySchema = z.object({
type: z.string().min(1),
})
const PublicResultSchema = z.object({
outcome: z.string().min(1),
summary: z.string(),
})
const protocol = new ConversationRunProtocolCodec({
activity: PublicActivitySchema,
result: PublicResultSchema,
})Payload validation must be synchronous because streaming parse and serialization are synchronous. The codec rejects an asynchronous validator with a clear boundary error. The validator output is the public projection: the Zod object above strips unknown internal activity fields. With another Standard Schema library, configure the equivalent allowlist behavior explicitly.
protocol.parseEvent(untrustedValue) validates:
- non-empty
runId; - positive safe-integer
sequence; - ISO timestamp;
- one of
activity,result,cancelled, orerror; - the host-supplied activity/result schema;
- JSON-safe values after schema parsing.
protocol.stringifyEvent(value) applies the same validation before JSON
serialization. This prevents passthrough or unknown payloads from carrying
values such as bigint, functions, symbols, non-finite numbers, or undefined
into a transport.
Use strict public schemas when internal activity can contain tool inputs, results, filesystem paths, or other sensitive data. The codec validates the schema you choose; it does not decide product authorization or sanitize secrets on your behalf.
The consumer is a transport-neutral state machine. A reference may include any
host fields as long as it has a stable runId:
import { ConversationRunConsumerService } from '@roackb2/heddle-remote'
type ProductRunReference = {
accountId: string
sessionId: string
runId: string
}
const consumer = new ConversationRunConsumerService<ProductRunReference>({
retry: {
maxAttempts: 6,
baseDelayMs: 500,
maxDelayMs: 4_000,
},
})
consumer.select({ accountId, sessionId, runId })Before opening a subscription, ask the consumer for the canonical cursor:
const input = consumer.subscriptionInput()
if (input) {
await transport.subscribe({
...input,
onEvent(rawEvent) {
const event = protocol.parseEvent(rawEvent)
const acceptance = consumer.accept(event)
if (acceptance.accepted) {
renderProductEvent(event)
}
},
})
}accept(...):
- ignores an event for another run;
- ignores an already accepted replay sequence;
- throws on a sequence gap;
- advances the cursor only after accepting an event;
- recognizes result/cancel/error as terminal;
- rejects a later event after terminal.
When a transport disconnects before terminal, request the next bounded retry:
const retry = consumer.nextRetry()
if (retry) {
await delay(retry.delayMs)
// reconnect with retry.input / consumer.subscriptionInput()
}The consumer computes retry correctness and timing. The host still owns the actual timer, subscription handle, error presentation, online/offline policy, and UI state. Accepted progress resets the retry attempt budget.
When the host exposes POST /runs, GET /runs/:runId/events, and
POST /runs/:runId/cancel, use the browser-safe preset instead of recreating
incremental SSE parsing and transport validation:
import { ConversationRunHttpSseClient } from '@roackb2/heddle-remote/http-sse'
const client = new ConversationRunHttpSseClient({
baseUrl: '/api/agent',
protocol,
accepted: StartRunResultSchema,
cancellation: CancelRunResultSchema,
getHeaders: () => ({ Authorization: `Bearer ${accessToken}` }),
})
const accepted = await client.start({ sessionId, prompt })
await client.subscribe({
runId: accepted.runId,
afterSequence: consumer.subscriptionInput()?.afterSequence,
signal: subscription.signal,
onEvent(event) {
consumer.accept(event)
},
})The preset owns URL encoding, header composition, response schema validation,
incremental SSE parsing, reader cleanup, and verification that the SSE ID,
event name, payload runId, and canonical envelope agree. The host supplies
auth headers, public schemas, abort/timer lifecycle, cursor persistence, retry
UX, and product event handling.
Use one host-long-lived ConversationRunService from the hosted entrypoint:
import { ConversationRunService } from '@roackb2/heddle/hosted'
const runs = new ConversationRunService<ProductRunAddress>({
addressKey: ({ accountId, sessionId }) => JSON.stringify([accountId, sessionId]),
})Authentication and authorization happen before the host constructs or resolves
the address. Do not treat possession of runId as authorization.
The run service remains process-local. Its replay buffer is bounded and does not promise restart recovery or cross-instance delivery. Add shared routing or durable delivery only when the deployment explicitly requires that additional assumption layer.
For the same conventional Node HTTP/SSE transport, import
parseConversationRunSseReplayCursor and streamConversationRunSse from
@roackb2/heddle/hosted/http-sse. They own cursor precedence, SSE headers and
frames, backpressure, and subscriber-only disconnect cleanup. They intentionally
do not register routes or choose authentication, authorization, CORS, rate
limits, request validation, or public error responses.
- start/cancel routes or procedures;
- routes/procedures and non-HTTP/SSE transport adapters;
- HTTP authentication, authorization, CORS, rate limits, and public errors;
- authentication, tenancy, CORS, rate limits, and audit;
- engine construction, credentials, tools, and approval policy;
- public activity/result projection;
- product finalization and UI state.
See the hosted-agent example for a runnable service → Express/SSE → browser flow. Its browser runner and Heddle's own CLI/web clients reuse this same consumer implementation.