diff --git a/README.md b/README.md
index 9d35e60c..9192c985 100644
--- a/README.md
+++ b/README.md
@@ -506,10 +506,11 @@ More: [Heartbeat guide](docs/guides/heartbeat.md)
### Programmatic Runtime
-Heddle is not only a CLI. The npm package exposes three main programmatic layers:
+Heddle is not only a CLI. The npm package exposes explicit programmatic layers:
- `createConversationEngine`: an alpha API for persisted multi-turn sessions with session storage, compaction, approvals, traces, semantic activity, and custom frontends or local hosts
-- `ConversationRunService`: process-local run identity, ordered activity, bounded replay, cancellation, and approval resolution for reconnectable hosted conversations
+- `@roackb2/heddle/hosted`: process-local run identity, ordered activity, bounded replay, cancellation, and approval resolution for reconnectable hosted conversations
+- `@roackb2/heddle/remote`: runtime-validated public run envelopes plus shared cursor, duplicate, gap, terminal, and reconnect correctness for remote clients
- `AgentLoopRuntimeService.run(...)`: a lower-level single-run execution loop for hosts that do not need persisted chat or session behavior
Advanced hosts can also reuse lower-level class APIs such as `ToolRegistry`, `ToolExecutionService`, `TraceRecorder`, `TraceConsoleFormatter`, and `ReviewDiffParser` when they intentionally assemble custom runtime or review surfaces.
@@ -518,6 +519,7 @@ Other exported primitives include `HeartbeatRunnerAgent.run`, `HeartbeatSchedule
More: [Programmatic hosts](docs/guides/programmatic/README.md),
[choosing an integration layer](docs/guides/programmatic/integration-layers.md),
+[remote conversation runs](docs/guides/programmatic/remote-runs.md),
and the runnable
[hosted service → HTTP/SSE → browser client example](examples/sdk/05-hosted-agent/README.md).
diff --git a/docs/architecture/live-events.md b/docs/architecture/live-events.md
index 6a2f5789..533231ac 100644
--- a/docs/architecture/live-events.md
+++ b/docs/architecture/live-events.md
@@ -24,7 +24,9 @@ The ownership split is:
- `src/core/chat/runs` owns run identity, ordering, replay, terminal state,
cancellation, and pending approval coordination;
- the control plane owns transport projection and lifecycle discovery;
-- `src/client-shared` owns frontend-neutral cursor and reconnect correctness;
+- `src/core/chat/remote` owns frontend-neutral cursor and reconnect correctness;
+- `src/client-shared` exposes that browser-safe policy to Heddle interfaces
+ without giving presentation code direct access to core internals;
- CLI and web own presentation state only.
## Activity And Run Vocabularies
@@ -75,8 +77,9 @@ flowchart TD
Run --> Adapter["Control-plane run-stream adapter"]
Adapter --> RunRPC["sessionRunEvents
runId + afterSequence"]
Adapter --> Lifecycle["sessionEvents
run started or settled"]
- RunRPC --> Shared["client-shared run cursor service"]
+ RunRPC --> Remote["ConversationRunConsumerService
cursor and reconnect policy"]
Lifecycle --> Shared
+ Remote --> Shared["client-shared public barrel"]
Shared --> CLI["cli-v2 presentation"]
Shared --> Web["web-v2 presentation"]
Engine --> Trace["TraceEvent
durable evidence"]
@@ -92,9 +95,10 @@ The main implementation path is:
- `chat-session-events.ts` fans lifecycle, queue, and approval signals through
a workspace/session-scoped `EventEmitter`;
- `controlPlane.sessionRunEvents` exposes the replayable run stream;
-- `ClientSharedConversationRunStreamService` enforces duplicate suppression,
+- `ConversationRunConsumerService` enforces duplicate suppression,
sequence-gap detection, terminal detection, and reconnect cursors;
-- cli-v2 and web-v2 bind that shared behavior to their local state models.
+- `src/client-shared` reexports that public remote-run service so cli-v2 and
+ web-v2 can bind it to their local state models without importing core.
Conversation activities are published only into `ConversationRunService`.
They are not also copied onto the session `EventEmitter`; one activity must not
@@ -205,7 +209,8 @@ The server keeps a bounded replay buffer for retained runs. A reconnecting
client sends the last accepted `sequence` as `afterSequence`; the server replays
only newer items and then resumes live delivery.
-`ClientSharedConversationRunStreamService` is the mandatory CLI/web policy:
+`ConversationRunConsumerService` from `@roackb2/heddle/remote` is the
+mandatory CLI/web policy:
- ignore duplicate sequence numbers;
- reject a gap instead of silently losing activity;
@@ -249,8 +254,8 @@ When adding shared behavior:
1. Add structured activity fields at the owning core origin.
2. Publish through the existing run context; do not add a second session bus.
3. Keep server projection limited to real transport/security work.
-4. Put cursor and reconnect policy in `client-shared` when both interfaces need
- it.
+4. Put transport-neutral cursor and reconnect policy in `src/core/chat/remote`;
+ expose it to Heddle presentation clients through `src/client-shared`.
5. Keep CLI/web differences limited to input and presentation.
6. Test run identity, ordered replay, terminal delivery, and reconnect behavior
before relying on interface-level snapshots.
diff --git a/docs/guides/programmatic/README.md b/docs/guides/programmatic/README.md
index 4aa9accf..33fd7371 100644
--- a/docs/guides/programmatic/README.md
+++ b/docs/guides/programmatic/README.md
@@ -8,14 +8,19 @@ tools, host extensions, and approval callbacks.
These guides follow a **progressive-disclosure ladder**. Start at rung 1 and go
deeper only when you need to. This mirrors how the public API is organized.
-There are two import entry points:
+The public entry points span two independent axes: customization depth and
+hosting assumptions.
- `@roackb2/heddle` — the **curated** default surface (rungs 1–5): everything a
product host needs to build an agentic experience.
-- `@roackb2/heddle/advanced` — the **full** surface: the curated exports plus
+- `@roackb2/heddle/hosted` — process-local run identity, replay, cancellation,
+ and approvals for a long-lived host process.
+- `@roackb2/heddle/remote` — browser-safe runtime contracts plus transport-
+ neutral cursor, duplicate, gap, terminal, and reconnect correctness.
+- `@roackb2/heddle/advanced` — the **deep core customization** surface: the curated exports plus
lower-level building blocks (LLM adapters, individual tools, trace, memory,
models, awareness) and specialized runtimes (agent loop, heartbeat,
- integrations). Reach for this only when the curated surface is not enough.
+ integrations). It does not implicitly opt into remote hosting or a transport.
Before choosing an API, read
[Choose a Programmatic Integration Layer](integration-layers.md). It maps common
@@ -38,6 +43,8 @@ split between Heddle and the host explicit for developers and coding agents.
- [Conversation engine](conversation-engine.md): engine setup, state roots,
and persisted sessions.
- [Approvals](approvals.md): own policy decisions in the host.
+ - [Remote conversation runs](remote-runs.md): consume reconnectable runs
+ through any transport with runtime-validated public payloads.
- [Hosted agent stack](../../../examples/sdk/05-hosted-agent/README.md): compose
a transport-neutral run service, Express/SSE API, and browser client while
keeping each layer replaceable.
diff --git a/docs/guides/programmatic/conversation-engine.md b/docs/guides/programmatic/conversation-engine.md
index 479fdf1c..f867de48 100644
--- a/docs/guides/programmatic/conversation-engine.md
+++ b/docs/guides/programmatic/conversation-engine.md
@@ -84,7 +84,8 @@ process-local run identity, cancellation, ordered activity delivery, approvals,
and bounded replay for reconnecting subscribers.
```ts
-import { ConversationRunService, createConversationEngine } from '@roackb2/heddle'
+import { createConversationEngine } from '@roackb2/heddle'
+import { ConversationRunService } from '@roackb2/heddle/hosted'
const runs = new ConversationRunService({
replay: { maxEventsPerRun: 512, retentionMs: 300_000 },
@@ -121,6 +122,10 @@ The replay buffer is intentionally process-local and bounded. Durable final
conversation state remains in the engine's session repository; transports and
cross-process delivery remain host responsibilities.
+When a remote client owns a reconnect cursor, pair this service with the
+[remote run consumer and protocol codec](remote-runs.md) rather than rebuilding
+duplicate/gap/terminal/retry behavior in the client.
+
For a complete runnable host, follow the
[hosted agent stack example](../../../examples/sdk/05-hosted-agent/README.md). It
uses this exact service for account-scoped start/subscribe/cancel, then adds an
diff --git a/docs/guides/programmatic/integration-layers.md b/docs/guides/programmatic/integration-layers.md
index 451feac9..13770efd 100644
--- a/docs/guides/programmatic/integration-layers.md
+++ b/docs/guides/programmatic/integration-layers.md
@@ -15,9 +15,9 @@ in control.
HOST-OWNED PRODUCT
UI state and rendering
|
- transport client and public wire contract optional
+ @roackb2/heddle/remote consumer + public contract
|
- server adapter, auth, tenancy, rate limits optional
+ transport client/server adapter optional
|
application service and composition root
(product session IDs, tools, config, repositories, policy)
@@ -44,10 +44,11 @@ not transport infrastructure.
| Agent execution | Model/tool loop, tool registry/execution, host-extension composition, traces, and artifacts | Product tools, system context, model choice, credentials, and capability policy |
| Approvals | Approval request/resolution lifecycle and run integration | Whether an action is allowed, authenticated approver, and approval UI/policy |
| Active runs | Run ID, ordered sequence, process-local replay, subscription, cancellation, and terminal result | Lifetime of the run service, address scope, process routing, draining, and multi-process delivery |
+| Remote consumption | Cursor advancement, duplicate suppression, sequence-gap failure, terminal detection, bounded reconnect timing, and runtime envelope validation | Public activity/result schemas, actual transport timer/handle, and error UX |
| Persistence | File-backed defaults plus injectable session/artifact repository ports | Production repository implementations, retention, encryption, backup, and tenancy |
| Identity and authorization | No identity-provider assumption | Authentication, tenant/user mapping, authorization for start/subscribe/cancel |
| Transport/API | No HTTP, tRPC, SSE, or WebSocket assumption | Framework, routes/procedures, wire schemas, errors, limits, CORS, and rate limiting |
-| Client experience | Semantic activities and terminal run events | Messages, tool rendering, UI state, retries, notifications, and product-specific result presentation |
+| Client experience | Semantic activities, terminal run events, and remote cursor/retry calculations | Messages, tool rendering, UI state, transport timers, retry UX, notifications, and product-specific result presentation |
Do not rebuild Heddle-owned conversation or run behavior in the host. In
particular, avoid replaying product chat history into every prompt, generating a
@@ -61,9 +62,10 @@ disconnect as implicit cancellation.
| Nothing beyond a TypeScript process | `runQuickstartConversationCli` | [`01-interactive-chat.ts`](../../../examples/sdk/01-interactive-chat.ts) |
| A local loop that needs product tools or MCP | Quickstart plus tools/host extensions | [`02-add-a-tool.ts`](../../../examples/sdk/02-add-a-tool.ts), [`03-add-an-mcp-server.ts`](../../../examples/sdk/03-add-an-mcp-server.ts) |
| Its own output sink or local UI | `createConversationEngine` + `createConversationTextHost` or host callbacks | [`04-custom-output.ts`](../../../examples/sdk/04-custom-output.ts) |
-| A server/worker that owns transport | `createConversationEngine` + `ConversationRunService` | [`05-hosted-agent/01-hosted-service`](../../../examples/sdk/05-hosted-agent/01-hosted-service) |
+| A server/worker that owns transport | `@roackb2/heddle` + `@roackb2/heddle/hosted` | [`05-hosted-agent/01-hosted-service`](../../../examples/sdk/05-hosted-agent/01-hosted-service) |
| Express with REST + SSE | Same core plus a host adapter | [`05-hosted-agent/02-http-sse-api`](../../../examples/sdk/05-hosted-agent/02-http-sse-api) |
-| A browser using that exact REST/SSE contract | Host API plus the protocol client | [`05-hosted-agent/03-browser-client`](../../../examples/sdk/05-hosted-agent/03-browser-client) |
+| A remote client over any transport | `@roackb2/heddle/remote` plus a host transport | [Remote conversation runs](remote-runs.md) |
+| A browser using the example REST/SSE contract | Remote layer plus the example protocol client | [`05-hosted-agent/03-browser-client`](../../../examples/sdk/05-hosted-agent/03-browser-client) |
For tRPC, Fastify, Hono, Nest, WebSocket, Electron IPC, queues, or another
transport, stop at the hosted-service layer and implement the adapter in the
@@ -93,6 +95,20 @@ lifetimes differ, clients can reconnect, or a turn needs a separately
addressable cancel/approval lifecycle. It is a process-local coordinator above
the engine, not a transport or durable message broker.
+Import it from `@roackb2/heddle/hosted` so the hosted-process assumption is
+visible. The root export remains available for compatibility.
+
+### Remote run protocol
+
+Use `ConversationRunProtocolCodec` and `ConversationRunConsumerService` from
+`@roackb2/heddle/remote` when events cross an untrusted transport boundary or a
+client can reconnect. The host supplies public activity/result schemas; Heddle
+owns envelope validation, JSON safety, cursor advancement, duplicate/gap
+handling, terminal detection, and retry calculation.
+
+This layer does not own HTTP, SSE, tRPC, timers, auth, or UI state. See
+[Remote conversation runs](remote-runs.md).
+
### Transport adapter
Add a host-owned adapter when a remote client needs start, subscribe, cancel, or
@@ -100,6 +116,10 @@ approval operations. Validate all untrusted wire data and project internal run
results into an explicitly public schema. Authentication and authorization must
happen before resolving the Heddle run address.
+Web-standard HTTP/SSE helpers are a planned higher assumption layer; Express
+and tRPC remain framework recipes until their residual code proves a meaningful
+adapter boundary.
+
### Client protocol and UI
Add a transport client only when it matches the host's API. Keep protocol
@@ -118,6 +138,8 @@ normal client architecture.
| Approval behavior | `ConversationEngineHost.approvals` and the product's policy/UI |
| Session/artifact storage | `ChatSessionRepository` and `ArtifactRepository` |
| Public API fields | Host-owned validation schemas and terminal-result projection |
+| Remote cursor/retry correctness | `ConversationRunConsumerService` |
+| Runtime run-envelope validation | `ConversationRunProtocolCodec` with host activity/result schemas |
| REST, tRPC, SSE, WebSocket, or IPC | Host transport adapter above the application service |
| React or other UI state | Product client/application layer above the protocol client |
| Multi-process live delivery | Host-selected shared routing/broker infrastructure |
diff --git a/docs/guides/programmatic/remote-runs.md b/docs/guides/programmatic/remote-runs.md
new file mode 100644
index 00000000..ea4ae77d
--- /dev/null
+++ b/docs/guides/programmatic/remote-runs.md
@@ -0,0 +1,163 @@
+# Remote Conversation Runs
+
+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:
+
+```ts
+import { createConversationEngine } from '@roackb2/heddle'
+import { ConversationRunService } from '@roackb2/heddle/hosted'
+import {
+ ConversationRunConsumerService,
+ ConversationRunProtocolCodec,
+} from '@roackb2/heddle/remote'
+```
+
+- `@roackb2/heddle` owns persisted conversation semantics.
+- `@roackb2/heddle/hosted` owns process-local active-run coordination.
+- `@roackb2/heddle/remote` owns client cursor correctness and runtime wire
+ validation without choosing HTTP, SSE, tRPC, WebSocket, React, or auth.
+
+## Define the public wire payload
+
+Heddle owns the run envelope and terminal vocabulary. The host must explicitly
+choose which activity and result fields are safe for remote clients.
+
+```ts
+import { z } from 'zod'
+import { ConversationRunProtocolCodec } from '@roackb2/heddle/remote'
+
+const PublicActivitySchema = z.object({
+ type: z.string().min(1),
+}).passthrough()
+
+const PublicResultSchema = z.object({
+ outcome: z.string().min(1),
+ summary: z.string(),
+})
+
+const protocol = new ConversationRunProtocolCodec({
+ activity: PublicActivitySchema,
+ result: PublicResultSchema,
+})
+```
+
+`protocol.parseEvent(untrustedValue)` validates:
+
+- non-empty `runId`;
+- positive safe-integer `sequence`;
+- ISO timestamp;
+- one of `activity`, `result`, `cancelled`, or `error`;
+- 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.
+
+## Consume one run correctly
+
+The consumer is a transport-neutral state machine. A reference may include any
+host fields as long as it has a stable `runId`:
+
+```ts
+import { ConversationRunConsumerService } from '@roackb2/heddle/remote'
+
+type ProductRunReference = {
+ accountId: string
+ sessionId: string
+ runId: string
+}
+
+const consumer = new ConversationRunConsumerService({
+ retry: {
+ maxAttempts: 6,
+ baseDelayMs: 500,
+ maxDelayMs: 4_000,
+ },
+})
+
+consumer.select({ accountId, sessionId, runId })
+```
+
+Before opening a subscription, ask the consumer for the canonical cursor:
+
+```ts
+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:
+
+```ts
+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.
+
+## Server-side run ownership
+
+Use one host-long-lived `ConversationRunService` from the hosted entrypoint:
+
+```ts
+import { ConversationRunService } from '@roackb2/heddle/hosted'
+
+const runs = new ConversationRunService({
+ 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.
+
+## What remains host-owned
+
+- start/cancel routes or procedures;
+- HTTP/SSE/tRPC/WebSocket adapters;
+- 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](../../../examples/sdk/05-hosted-agent/README.md)
+for a runnable service → Express/SSE → browser flow. Its browser runner and
+Heddle's own CLI/web clients reuse this same consumer implementation.
diff --git a/examples/sdk/05-hosted-agent/01-hosted-service/README.md b/examples/sdk/05-hosted-agent/01-hosted-service/README.md
index 92af50fb..1f805209 100644
--- a/examples/sdk/05-hosted-agent/01-hosted-service/README.md
+++ b/examples/sdk/05-hosted-agent/01-hosted-service/README.md
@@ -17,7 +17,7 @@ for the runnable flow and production checklist.
## Read in this order
1. [`agent-service.ts`](agent-service.ts) — application-owned scope and lifecycle
- composed over Heddle's `ConversationRunService`.
+ composed over `ConversationRunService` from `@roackb2/heddle/hosted`.
2. [`example-agent.ts`](example-agent.ts) — replaceable local composition and
demo policy.
3. [`run.ts`](run.ts) — disconnect, cursor replay, and explicit cancellation in
diff --git a/examples/sdk/05-hosted-agent/01-hosted-service/agent-service.ts b/examples/sdk/05-hosted-agent/01-hosted-service/agent-service.ts
index 12e282a5..3ee56142 100644
--- a/examples/sdk/05-hosted-agent/01-hosted-service/agent-service.ts
+++ b/examples/sdk/05-hosted-agent/01-hosted-service/agent-service.ts
@@ -6,14 +6,16 @@
* transport, or UI dependency; those belong in later optional stages.
*/
import {
- ConversationRunService,
type ConversationEngine,
type ConversationEngineHost,
+ type ConversationTurnResultSummary,
+} from '../../../../src/index.js';
+import {
+ ConversationRunService,
type ConversationRunHandle,
type ConversationRunReplayOptions,
type ConversationRunStreamItem,
- type ConversationTurnResultSummary,
-} from '../../../../src/index.js';
+} from '../../../../src/hosted.js';
const DEFAULT_RUN_RETENTION_MS = 5 * 60_000;
diff --git a/examples/sdk/05-hosted-agent/02-http-sse-api/README.md b/examples/sdk/05-hosted-agent/02-http-sse-api/README.md
index df55c35a..e4bec1a6 100644
--- a/examples/sdk/05-hosted-agent/02-http-sse-api/README.md
+++ b/examples/sdk/05-hosted-agent/02-http-sse-api/README.md
@@ -14,7 +14,8 @@ for curl commands, lifecycle details, and production replacements.
## Read in this order
-1. [`contracts.ts`](contracts.ts) — Zod schemas for untrusted public wire data.
+1. [`contracts.ts`](contracts.ts) — host-owned public payload schemas composed
+ with `ConversationRunProtocolCodec` from `@roackb2/heddle/remote`.
2. [`http-api.ts`](http-api.ts) — authenticated start, cursor subscribe, and
explicit cancel handlers.
3. [`server.ts`](server.ts) — runnable local composition with deliberately
diff --git a/examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts b/examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts
index 4f907935..3310fa9f 100644
--- a/examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts
+++ b/examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts
@@ -5,6 +5,7 @@
* Extend these schemas only with product data safe for remote clients.
*/
import { z } from 'zod';
+import { ConversationRunProtocolCodec } from '../../../../src/remote.js';
export const StartHostedAgentRunInputSchema = z.object({
sessionId: z.string().trim().min(1).max(128),
@@ -18,38 +19,17 @@ export const StartHostedAgentRunResultSchema = z.object({
sessionId: z.string().min(1),
});
-const RunEventEnvelopeSchema = z.object({
- runId: z.string().min(1),
- sequence: z.number().int().positive().safe(),
- timestamp: z.iso.datetime(),
+export const HostedAgentRunProtocol = new ConversationRunProtocolCodec({
+ activity: z.object({
+ type: z.string().min(1),
+ }).passthrough(),
+ result: z.object({
+ outcome: z.string().min(1),
+ summary: z.string(),
+ }),
});
-export const HostedAgentRunEventSchema = z.discriminatedUnion('kind', [
- RunEventEnvelopeSchema.extend({
- kind: z.literal('activity'),
- activity: z.object({
- type: z.string().min(1),
- }).passthrough(),
- }),
- RunEventEnvelopeSchema.extend({
- kind: z.literal('result'),
- result: z.object({
- outcome: z.string().min(1),
- summary: z.string(),
- }),
- }),
- RunEventEnvelopeSchema.extend({
- kind: z.literal('cancelled'),
- reason: z.string(),
- }),
- RunEventEnvelopeSchema.extend({
- kind: z.literal('error'),
- error: z.object({
- code: z.literal('run_failed'),
- message: z.string(),
- }),
- }),
-]);
+export const HostedAgentRunEventSchema = HostedAgentRunProtocol.eventSchema;
export const CancelHostedAgentRunResultSchema = z.object({
cancelled: z.boolean(),
diff --git a/examples/sdk/05-hosted-agent/02-http-sse-api/http-api.ts b/examples/sdk/05-hosted-agent/02-http-sse-api/http-api.ts
index 658b37d9..565fdcfb 100644
--- a/examples/sdk/05-hosted-agent/02-http-sse-api/http-api.ts
+++ b/examples/sdk/05-hosted-agent/02-http-sse-api/http-api.ts
@@ -22,6 +22,7 @@ import {
import {
CancelHostedAgentRunResultSchema,
HostedAgentApiErrorSchema,
+ HostedAgentRunProtocol,
HostedAgentRunEventSchema,
StartHostedAgentRunInputSchema,
StartHostedAgentRunResultSchema,
@@ -153,7 +154,7 @@ async function writeSseEvent(
event: HostedAgentRunEvent,
signal: AbortSignal,
): Promise {
- const frame = `event: ${event.kind}\nid: ${event.sequence}\ndata: ${JSON.stringify(event)}\n\n`;
+ const frame = `event: ${event.kind}\nid: ${event.sequence}\ndata: ${HostedAgentRunProtocol.stringifyEvent(event)}\n\n`;
if (response.write(frame)) {
return;
}
diff --git a/examples/sdk/05-hosted-agent/03-browser-client/README.md b/examples/sdk/05-hosted-agent/03-browser-client/README.md
index d6d623fa..45563822 100644
--- a/examples/sdk/05-hosted-agent/03-browser-client/README.md
+++ b/examples/sdk/05-hosted-agent/03-browser-client/README.md
@@ -15,8 +15,8 @@ for the runnable reconnect/cancel flow.
1. [`browser-client.ts`](browser-client.ts) — URL/auth/error/SSE parsing, schema
validation, cursor validation, and abort propagation.
-2. [`run.ts`](run.ts) — application-owned reconnect, terminal-event, and cancel
- policy without a UI framework.
+2. [`run.ts`](run.ts) — Heddle's public remote consumer composed with
+ application-owned transport timers, terminal rendering, and cancel policy.
Keep messages, tool rendering, optimistic state, retry UX, notifications, and
product-specific results above this protocol client. A React application should
diff --git a/examples/sdk/05-hosted-agent/03-browser-client/browser-client.ts b/examples/sdk/05-hosted-agent/03-browser-client/browser-client.ts
index 26af4cb3..11cbb9c4 100644
--- a/examples/sdk/05-hosted-agent/03-browser-client/browser-client.ts
+++ b/examples/sdk/05-hosted-agent/03-browser-client/browser-client.ts
@@ -12,7 +12,7 @@ import type { ZodType } from 'zod';
import {
CancelHostedAgentRunResultSchema,
HostedAgentApiErrorSchema,
- HostedAgentRunEventSchema,
+ HostedAgentRunProtocol,
StartHostedAgentRunResultSchema,
type CancelHostedAgentRunResult,
type HostedAgentRunEvent,
@@ -161,7 +161,7 @@ function parseEvent(message: EventSourceMessage): HostedAgentRunEvent {
throw new HostedAgentClientError('Hosted agent event contained invalid JSON.');
}
- const event = HostedAgentRunEventSchema.parse(body);
+ const event = HostedAgentRunProtocol.parseEvent(body);
if (message.id !== String(event.sequence)) {
throw new HostedAgentClientError('Hosted agent event ID did not match its canonical sequence.');
}
diff --git a/examples/sdk/05-hosted-agent/03-browser-client/run.ts b/examples/sdk/05-hosted-agent/03-browser-client/run.ts
index 64160dfc..2e5dd0e0 100644
--- a/examples/sdk/05-hosted-agent/03-browser-client/run.ts
+++ b/examples/sdk/05-hosted-agent/03-browser-client/run.ts
@@ -6,12 +6,16 @@
* Start the stage-2 server first, then run this script with the same token.
*/
import { setTimeout as delay } from 'node:timers/promises';
+import { ConversationRunConsumerService } from '../../../../src/remote.js';
import {
HostedAgentClient,
HostedAgentClientError,
} from './browser-client.js';
import type { HostedAgentRunEvent } from '../02-http-sse-api/contracts.js';
+type HostedAgentRunReference = { runId: string };
+type HostedAgentRunConsumer = ConversationRunConsumerService;
+
const bearerToken = process.env.HEDDLE_EXAMPLE_BEARER_TOKEN;
if (!bearerToken) {
throw new Error('Set HEDDLE_EXAMPLE_BEARER_TOKEN to the secret used by the hosted API example.');
@@ -32,9 +36,11 @@ const client = new HostedAgentClient({
const accepted = await client.start({ sessionId, prompt });
console.log(`Accepted ${accepted.runId}.`);
-const cursor = await disconnectAfterFirstActivity(client, accepted.runId);
+const consumer = createRunConsumer(accepted.runId);
+await disconnectAfterFirstActivity(client, consumer);
+const cursor = requireSubscriptionInput(consumer).afterSequence;
console.log(`Browser subscription disconnected at sequence ${cursor}; reconnecting.`);
-const terminal = await consumeUntilTerminal(client, accepted.runId, cursor);
+const terminal = await consumeUntilTerminal(client, consumer);
console.log(`Run settled as ${terminal.kind}.`);
if (cancelDemo) {
@@ -44,22 +50,38 @@ if (cancelDemo) {
});
const cancelled = await client.cancel(cancellation.runId);
console.log(`Cancellation accepted: ${cancelled.cancelled}.`);
- const cancelledTerminal = await consumeUntilTerminal(client, cancellation.runId, 0);
+ const cancelledTerminal = await consumeUntilTerminal(
+ client,
+ createRunConsumer(cancellation.runId),
+ );
if (cancelledTerminal.kind !== 'cancelled') {
throw new Error(`Expected a cancelled terminal, received ${cancelledTerminal.kind}.`);
}
}
-async function disconnectAfterFirstActivity(agent: HostedAgentClient, runId: string): Promise {
+function createRunConsumer(runId: string): HostedAgentRunConsumer {
+ const consumer = new ConversationRunConsumerService({
+ retry: { maxAttempts: 5, baseDelayMs: 250, maxDelayMs: 2_000 },
+ });
+ consumer.select({ runId });
+ return consumer;
+}
+
+async function disconnectAfterFirstActivity(
+ agent: HostedAgentClient,
+ consumer: HostedAgentRunConsumer,
+): Promise {
const subscription = new AbortController();
- let cursor = 0;
let disconnected = false;
try {
await agent.subscribe({
- runId,
+ ...requireSubscriptionInput(consumer),
signal: subscription.signal,
onEvent: (event) => {
- cursor = event.sequence;
+ const acceptance = consumer.accept(event);
+ if (!acceptance.accepted) {
+ return;
+ }
renderEvent(event);
if (event.kind === 'activity') {
disconnected = true;
@@ -75,47 +97,63 @@ async function disconnectAfterFirstActivity(agent: HostedAgentClient, runId: str
if (!disconnected) {
throw new Error('The first subscription ended before receiving an activity.');
}
- return cursor;
}
async function consumeUntilTerminal(
agent: HostedAgentClient,
- runId: string,
- initialCursor: number,
+ consumer: HostedAgentRunConsumer,
): Promise {
- let cursor = initialCursor;
let terminal: HostedAgentRunEvent | undefined;
+ let lastError: unknown;
- for (let attempt = 0; attempt < 5 && !terminal; attempt += 1) {
+ while (!terminal) {
try {
await agent.subscribe({
- runId,
- afterSequence: cursor,
+ ...requireSubscriptionInput(consumer),
onEvent: (event) => {
- cursor = Math.max(cursor, event.sequence);
+ const acceptance = consumer.accept(event);
+ if (!acceptance.accepted) {
+ return;
+ }
renderEvent(event);
- if (event.kind !== 'activity') {
+ if (acceptance.terminal) {
terminal = event;
}
},
});
+ if (!terminal) {
+ lastError = new Error('Hosted agent event stream ended before a terminal event.');
+ }
} catch (error) {
if (error instanceof HostedAgentClientError
&& (error.status === undefined || error.status < 500)) {
throw error;
}
+ lastError = error;
}
+
if (!terminal) {
- await delay(Math.min(250 * (2 ** attempt), 2_000));
+ const retry = consumer.nextRetry();
+ if (!retry) {
+ throw lastError instanceof Error
+ ? lastError
+ : new Error('Hosted agent run exhausted its reconnect attempts.');
+ }
+ await delay(retry.delayMs);
}
}
- if (!terminal) {
- throw new Error(`Run ${runId} did not reach a terminal event after bounded reconnects.`);
- }
return terminal;
}
+function requireSubscriptionInput(consumer: HostedAgentRunConsumer) {
+ const input = consumer.subscriptionInput();
+ if (!input) {
+ throw new Error('Hosted agent run no longer accepts subscriptions.');
+ }
+ return input;
+}
+
function renderEvent(event: HostedAgentRunEvent): void {
if (event.kind === 'activity') {
console.log(`[${event.sequence}] activity ${event.activity.type}`);
diff --git a/examples/sdk/05-hosted-agent/README.md b/examples/sdk/05-hosted-agent/README.md
index 32593795..91d24ecf 100644
--- a/examples/sdk/05-hosted-agent/README.md
+++ b/examples/sdk/05-hosted-agent/README.md
@@ -23,8 +23,9 @@ Only the first two assumptions are fundamental to the hosted-service pattern.
Express, SSE, bearer auth, and the sample browser client are replaceable host
choices.
-When copying this example into another project, replace the relative
-`src/index.js` imports with `@roackb2/heddle`. Stage 01 requires Heddle only;
+When copying this example into another project, replace the relative source
+imports with `@roackb2/heddle`, `@roackb2/heddle/hosted`, and
+`@roackb2/heddle/remote` as shown by each layer. Stage 01 requires Heddle only;
stage 02 additionally uses `express`, its TypeScript types, and `zod`; stage 03
uses `eventsource-parser` and the shared Zod wire contracts. Declare those
libraries directly in the host project instead of relying on transitive
@@ -51,14 +52,14 @@ dependencies.
|
v
02-http-sse-api/
- contracts.ts untrusted public wire schemas
+ contracts.ts public schemas + Heddle remote protocol codec
http-api.ts Express start/subscribe/cancel + SSE adapter
server.ts runnable local server and demo authentication
|
v
03-browser-client/
browser-client.ts typed fetch/SSE protocol adapter
- run.ts reconnect, cursor, and terminal-policy demo
+ run.ts public consumer + transport reconnect demo
```
The dependency direction is intentional:
@@ -145,7 +146,8 @@ POST /api/agent/runs/:runId/cancel
### What it showcases
[`contracts.ts`](02-http-sse-api/contracts.ts) validates untrusted wire data and
-defines the public browser contract. [`http-api.ts`](02-http-sse-api/http-api.ts)
+defines the public browser contract through Heddle's
+`ConversationRunProtocolCodec`. [`http-api.ts`](02-http-sse-api/http-api.ts)
adapts HTTP operations to the transport-neutral service:
- start returns `202 Accepted` after Heddle assigns a stable run identity;
@@ -216,10 +218,11 @@ parsing with `eventsource-parser`, Zod validation, event-ID validation, and
abort propagation.
[`run.ts`](03-browser-client/run.ts) is the consuming application policy. It
-retains the greatest received sequence, deliberately disconnects after one
-activity, reconnects with bounded exponential backoff, and stops on a
-result/cancel/error terminal. Add `--cancel-demo` to exercise explicit
-cancellation.
+uses Heddle's `ConversationRunConsumerService` for cursor advancement,
+duplicate/gap handling, terminal detection, and bounded reconnect timing. The
+runner deliberately disconnects after one activity while retaining ownership
+of the actual timer and transport lifecycle. Add `--cancel-demo` to exercise
+explicit cancellation.
### Responsibility boundary
diff --git a/examples/sdk/README.md b/examples/sdk/README.md
index 4bbeb504..94ff5c7c 100644
--- a/examples/sdk/README.md
+++ b/examples/sdk/README.md
@@ -30,7 +30,7 @@ platform, an identity provider, or a database.
| A process that already owns output/rendering | [`04-custom-output.ts`](04-custom-output.ts) | Output sink and presentation policy |
| A server, worker, Electron backend, or custom transport | [`05-hosted-agent/01-hosted-service`](05-hosted-agent/01-hosted-service) | Identity scope, durable session IDs, engine composition, and process lifetime |
| An Express server using REST + SSE | [`05-hosted-agent/02-http-sse-api`](05-hosted-agent/02-http-sse-api) | Authentication, API policy, HTTP operations, and deployment |
-| A browser consuming that REST + SSE contract | [`05-hosted-agent/03-browser-client`](05-hosted-agent/03-browser-client) | UI state, rendering, retry UX, and product result handling |
+| A browser consuming that REST + SSE contract | [`05-hosted-agent/03-browser-client`](05-hosted-agent/03-browser-client) | Transport lifecycle, UI state, rendering, retry UX, and product result handling |
If your server already uses tRPC, Fastify, Hono, Nest, WebSocket, or another
transport, follow the hosted-service stage and write an adapter for that stack.
@@ -89,7 +89,8 @@ This directory contains its own numbered path:
1. `01-hosted-service` — transport-neutral account/session/run lifecycle.
2. `02-http-sse-api` — optional Express + REST/SSE adapter.
-3. `03-browser-client` — optional framework-neutral browser protocol client.
+3. `03-browser-client` — optional framework-neutral browser protocol client
+ composed with Heddle's public remote-run consumer.
Read the [hosted-agent walkthrough](05-hosted-agent/README.md) before copying
this stage. Each later folder depends only on the earlier layer it extends, so
diff --git a/package.json b/package.json
index b5d688c7..18e16ef3 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,14 @@
"types": "./dist/src/index.d.ts",
"import": "./dist/src/index.js"
},
+ "./hosted": {
+ "types": "./dist/src/hosted.d.ts",
+ "import": "./dist/src/hosted.js"
+ },
+ "./remote": {
+ "types": "./dist/src/remote.d.ts",
+ "import": "./dist/src/remote.js"
+ },
"./advanced": {
"types": "./dist/src/advanced.d.ts",
"import": "./dist/src/advanced.js"
diff --git a/src/__tests__/integration/sdk/public-hosting-entrypoints.test.ts b/src/__tests__/integration/sdk/public-hosting-entrypoints.test.ts
new file mode 100644
index 00000000..b194370c
--- /dev/null
+++ b/src/__tests__/integration/sdk/public-hosting-entrypoints.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+import { ConversationRunService as CuratedConversationRunService } from '../../../index.js';
+import { ConversationRunService as HostedConversationRunService } from '../../../hosted.js';
+import {
+ ConversationRunConsumerService,
+ ConversationRunProtocolCodec,
+} from '../../../remote.js';
+import { z } from 'zod';
+
+describe('public hosting entrypoints', () => {
+ it('exposes the existing run coordinator through the explicit hosted subpath', () => {
+ expect(HostedConversationRunService).toBe(CuratedConversationRunService);
+ });
+
+ it('exposes the browser-safe remote consumer and protocol codec', () => {
+ const consumer = new ConversationRunConsumerService();
+ const codec = new ConversationRunProtocolCodec({
+ activity: z.object({ type: z.string() }),
+ result: z.object({ summary: z.string() }),
+ });
+
+ expect(consumer.select({ runId: 'run-1' })).toBe(true);
+ expect(codec.parseEvent({
+ kind: 'result',
+ runId: 'run-1',
+ sequence: 1,
+ timestamp: '2026-07-11T00:00:00.000Z',
+ result: { summary: 'Done' },
+ })).toMatchObject({ kind: 'result', result: { summary: 'Done' } });
+ });
+});
diff --git a/src/__tests__/unit/client-shared/conversation-run-stream-service.test.ts b/src/__tests__/unit/client-shared/conversation-run-stream-service.test.ts
deleted file mode 100644
index b19ae57e..00000000
--- a/src/__tests__/unit/client-shared/conversation-run-stream-service.test.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import { ClientSharedConversationRunStreamService } from '@/client-shared/services/conversation-run-stream/index.js';
-import type { ControlPlaneSessionRunEventEnvelope } from '@/client-shared/api/types.js';
-
-describe('ClientSharedConversationRunStreamService', () => {
- it('advances replay cursors, suppresses duplicates, and resumes from the latest sequence', () => {
- const service = new ClientSharedConversationRunStreamService();
- service.select({ workspaceId: 'workspace-1', sessionId: 'session-1', runId: 'run-1' });
-
- expect(service.accept(activity(1))).toEqual({ accepted: true, terminal: false });
- expect(service.accept(activity(1))).toEqual({ accepted: false, terminal: false });
- expect(service.nextRetry()).toEqual({
- attempt: 1,
- delayMs: 500,
- input: {
- workspaceId: 'workspace-1',
- sessionId: 'session-1',
- runId: 'run-1',
- afterSequence: 1,
- },
- });
- });
-
- it('rejects sequence gaps instead of silently losing run activity', () => {
- const service = new ClientSharedConversationRunStreamService();
- service.select({ workspaceId: 'workspace-1', sessionId: 'session-1', runId: 'run-1' });
-
- expect(() => service.accept(activity(2))).toThrow('expected 1, received 2');
- });
-
- it('stops reconnecting after a terminal item and resets for the next run', () => {
- const service = new ClientSharedConversationRunStreamService();
- service.select({ workspaceId: 'workspace-1', sessionId: 'session-1', runId: 'run-1' });
-
- expect(service.accept(result(1))).toEqual({ accepted: true, terminal: true });
- expect(service.nextRetry()).toBeUndefined();
-
- expect(service.select({ workspaceId: 'workspace-1', sessionId: 'session-1', runId: 'run-2' })).toBe(true);
- expect(service.subscriptionInput()).toEqual({
- workspaceId: 'workspace-1',
- sessionId: 'session-1',
- runId: 'run-2',
- afterSequence: 0,
- });
- });
-});
-
-function activity(sequence: number): ControlPlaneSessionRunEventEnvelope {
- return {
- kind: 'activity',
- runId: 'run-1',
- sequence,
- timestamp: '2026-07-11T00:00:00.000Z',
- activity: {
- source: 'agent-loop',
- type: 'assistant.stream',
- runId: 'run-1',
- step: 1,
- text: 'Working',
- done: false,
- timestamp: '2026-07-11T00:00:00.000Z',
- },
- };
-}
-
-function result(sequence: number): ControlPlaneSessionRunEventEnvelope {
- return {
- kind: 'result',
- runId: 'run-1',
- sequence,
- timestamp: '2026-07-11T00:00:01.000Z',
- result: {},
- };
-}
diff --git a/src/__tests__/unit/core/conversation-run-consumer.test.ts b/src/__tests__/unit/core/conversation-run-consumer.test.ts
new file mode 100644
index 00000000..1911b5db
--- /dev/null
+++ b/src/__tests__/unit/core/conversation-run-consumer.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from 'vitest';
+import {
+ ConversationRunConsumerService,
+ ConversationRunSequenceGapError,
+ ConversationRunTerminalViolationError,
+ type ConversationRunConsumerEvent,
+ type ConversationRunReference,
+} from '@/core/chat/remote/index.js';
+
+type TestRunReference = ConversationRunReference & {
+ workspaceId: string;
+ sessionId: string;
+};
+
+describe('ConversationRunConsumerService', () => {
+ it('advances cursors, suppresses duplicates, and preserves host reference fields', () => {
+ const service = createConsumer();
+ service.select(run('run-1'));
+
+ expect(service.accept(event('activity', 1))).toEqual({ accepted: true, terminal: false });
+ expect(service.accept(event('activity', 1))).toEqual({ accepted: false, terminal: false });
+ expect(service.nextRetry()).toEqual({
+ attempt: 1,
+ delayMs: 500,
+ input: {
+ workspaceId: 'workspace-1',
+ sessionId: 'session-1',
+ runId: 'run-1',
+ afterSequence: 1,
+ },
+ });
+ });
+
+ it('ignores events from another run without changing the selected cursor', () => {
+ const service = createConsumer();
+ service.select(run('run-1'));
+
+ expect(service.accept(event('activity', 1, 'run-2'))).toEqual({ accepted: false, terminal: false });
+ expect(service.subscriptionInput()?.afterSequence).toBe(0);
+ });
+
+ it('rejects sequence gaps instead of silently losing activity', () => {
+ const service = createConsumer();
+ service.select(run('run-1'));
+
+ expect(() => service.accept(event('activity', 2))).toThrow(ConversationRunSequenceGapError);
+ expect(() => service.accept(event('activity', 2))).toThrow('expected 1, received 2');
+ });
+
+ it('stops after one terminal and rejects later non-duplicate events', () => {
+ const service = createConsumer();
+ service.select(run('run-1'));
+
+ expect(service.accept(event('result', 1))).toEqual({ accepted: true, terminal: true });
+ expect(service.accept(event('result', 1))).toEqual({ accepted: false, terminal: true });
+ expect(service.nextRetry()).toBeUndefined();
+ expect(() => service.accept(event('error', 2))).toThrow(ConversationRunTerminalViolationError);
+ });
+
+ it('resets retry attempts after progress and caps exponential delay', () => {
+ const service = new ConversationRunConsumerService({
+ retry: { maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 150 },
+ });
+ service.select(run('run-1'));
+
+ expect(service.nextRetry()?.delayMs).toBe(100);
+ expect(service.nextRetry()?.delayMs).toBe(150);
+ expect(service.accept(event('activity', 1))).toEqual({ accepted: true, terminal: false });
+ expect(service.nextRetry()).toMatchObject({ attempt: 1, delayMs: 100 });
+ expect(service.nextRetry()).toMatchObject({ attempt: 2, delayMs: 150 });
+ expect(service.nextRetry()).toMatchObject({ attempt: 3, delayMs: 150 });
+ expect(service.nextRetry()).toBeUndefined();
+ });
+
+ it('resets cleanly for a new run and rejects invalid inputs', () => {
+ const service = createConsumer();
+ service.select(run('run-1'));
+ service.accept(event('cancelled', 1));
+
+ expect(service.select(run('run-2'))).toBe(true);
+ expect(service.subscriptionInput()).toMatchObject({ runId: 'run-2', afterSequence: 0 });
+ expect(() => service.select(run(' '))).toThrow('non-empty runId');
+ expect(() => service.accept(event('activity', 0, 'run-2'))).toThrow('positive safe integer');
+ });
+
+ it('validates retry configuration once at construction', () => {
+ expect(() => new ConversationRunConsumerService({
+ retry: { maxAttempts: -1 },
+ })).toThrow('non-negative safe integer');
+ expect(() => new ConversationRunConsumerService({
+ retry: { baseDelayMs: 500, maxDelayMs: 100 },
+ })).toThrow('cannot be less than its base delay');
+ });
+});
+
+function createConsumer(): ConversationRunConsumerService {
+ return new ConversationRunConsumerService();
+}
+
+function run(runId: string): TestRunReference {
+ return {
+ workspaceId: 'workspace-1',
+ sessionId: 'session-1',
+ runId,
+ };
+}
+
+function event(
+ kind: ConversationRunConsumerEvent['kind'],
+ sequence: number,
+ runId = 'run-1',
+): ConversationRunConsumerEvent {
+ return { kind, runId, sequence };
+}
diff --git a/src/__tests__/unit/core/conversation-run-protocol-codec.test.ts b/src/__tests__/unit/core/conversation-run-protocol-codec.test.ts
new file mode 100644
index 00000000..720b3a44
--- /dev/null
+++ b/src/__tests__/unit/core/conversation-run-protocol-codec.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest';
+import { z } from 'zod';
+import { ConversationRunProtocolCodec } from '@/core/chat/remote/index.js';
+
+const PublicActivitySchema = z.object({
+ type: z.string().min(1),
+}).passthrough();
+const PublicResultSchema = z.object({
+ outcome: z.string().min(1),
+ summary: z.string(),
+});
+
+describe('ConversationRunProtocolCodec', () => {
+ it('validates every canonical event kind', () => {
+ const codec = createCodec();
+
+ expect(codec.parseEvent(envelope({
+ kind: 'activity',
+ activity: { type: 'assistant.stream', text: 'Working' },
+ }))).toMatchObject({ kind: 'activity', activity: { text: 'Working' } });
+ expect(codec.parseEvent(envelope({
+ kind: 'result',
+ result: { outcome: 'done', summary: 'Finished' },
+ }))).toMatchObject({ kind: 'result', result: { outcome: 'done' } });
+ expect(codec.parseEvent(envelope({
+ kind: 'cancelled',
+ reason: 'Cancelled by user',
+ }))).toMatchObject({ kind: 'cancelled' });
+ expect(codec.parseEvent(envelope({
+ kind: 'error',
+ error: { code: 'run_failed', message: 'Failed' },
+ }))).toMatchObject({ kind: 'error' });
+ });
+
+ it('applies the host result schema before data crosses the boundary', () => {
+ const event = createCodec().parseEvent(envelope({
+ kind: 'result',
+ result: {
+ outcome: 'done',
+ summary: 'Public summary',
+ internalSession: { secret: true },
+ },
+ }));
+
+ expect(event).toEqual(envelope({
+ kind: 'result',
+ result: { outcome: 'done', summary: 'Public summary' },
+ }));
+ });
+
+ it('rejects malformed envelopes and host payloads', () => {
+ const codec = createCodec();
+
+ expect(() => codec.parseEvent({
+ ...envelope({ kind: 'cancelled', reason: 'Cancelled' }),
+ sequence: 0,
+ })).toThrow();
+ expect(() => codec.parseEvent({
+ ...envelope({ kind: 'cancelled', reason: 'Cancelled' }),
+ timestamp: 'not-a-date',
+ })).toThrow();
+ expect(() => codec.parseEvent(envelope({
+ kind: 'result',
+ result: { outcome: 'done' },
+ }))).toThrow();
+ });
+
+ it('rejects non-JSON-safe values after host schema parsing', () => {
+ const codec = createCodec();
+
+ expect(() => codec.parseEvent(envelope({
+ kind: 'activity',
+ activity: { type: 'tool.calling', unsafe: 1n },
+ }))).toThrow('JSON-safe');
+ expect(() => codec.parseEvent(envelope({
+ kind: 'activity',
+ activity: { type: 'tool.calling', unsafe: undefined },
+ }))).toThrow('JSON-safe');
+ });
+
+ it('round-trips validated events through JSON serialization', () => {
+ const codec = createCodec();
+ const input = envelope({
+ kind: 'result',
+ result: { outcome: 'done', summary: 'Finished' },
+ });
+
+ expect(codec.parseEvent(JSON.parse(codec.stringifyEvent(input)))).toEqual(input);
+ expect(codec.safeParseEvent(input).success).toBe(true);
+ });
+});
+
+function createCodec() {
+ return new ConversationRunProtocolCodec({
+ activity: PublicActivitySchema,
+ result: PublicResultSchema,
+ });
+}
+
+function envelope(payload: Record) {
+ return {
+ runId: 'run-1',
+ sequence: 1,
+ timestamp: '2026-07-11T00:00:00.000Z',
+ ...payload,
+ };
+}
diff --git a/src/advanced.ts b/src/advanced.ts
index 7052e3e2..372fe682 100644
--- a/src/advanced.ts
+++ b/src/advanced.ts
@@ -1,9 +1,11 @@
// ===========================================================================
// Heddle — Advanced / full surface (`@roackb2/heddle/advanced`)
// ===========================================================================
-// The complete public surface: the curated SDK (rungs 1-5, re-exported below)
-// plus lower-level building blocks and specialized runtimes. Reach for this
-// entry when you need LLM adapters, individual ready-made tools, trace/memory
+// The deep core-customization surface: the curated SDK (rungs 1-5, re-exported
+// below) plus lower-level building blocks and specialized runtimes. Remote
+// hosting remains an orthogonal opt-in through `@roackb2/heddle/hosted` and
+// `@roackb2/heddle/remote`; those subpaths are not re-exported here. Reach for
+// this entry when you need LLM adapters, individual ready-made tools, trace/memory
// internals, the agent loop, heartbeat, or integrations. Most product hosts
// only need the curated default entry (`@roackb2/heddle`, see src/index.ts).
// ===========================================================================
diff --git a/src/cli-v2/services/sessions/control-plane-session-subscription-service.ts b/src/cli-v2/services/sessions/control-plane-session-subscription-service.ts
index 8f5d58c8..ed3fdcf5 100644
--- a/src/cli-v2/services/sessions/control-plane-session-subscription-service.ts
+++ b/src/cli-v2/services/sessions/control-plane-session-subscription-service.ts
@@ -5,14 +5,19 @@ import type {
ControlPlaneSessionsEventEnvelope,
} from '@/client-shared/api/types.js';
import {
- ClientSharedConversationRunStreamService,
- type ClientSharedConversationRunReference,
+ ConversationRunConsumerService,
+ type ConversationRunReference,
} from '@/client-shared/services/conversation-run-stream/index.js';
type SubscriptionHandle = {
unsubscribe: () => void;
};
+type ControlPlaneConversationRunReference = ConversationRunReference & {
+ workspaceId: string;
+ sessionId: string;
+};
+
export type ControlPlaneSessionSubscriptionServiceOptions = {
client: ControlPlaneProxyClient;
onSessionsUpdated: () => void;
@@ -37,7 +42,7 @@ export class ControlPlaneSessionSubscriptionService {
private runSubscription?: SubscriptionHandle;
private runReconnectTimer?: ReturnType;
private sessionAddress?: { workspaceId: string; sessionId: string };
- private readonly runStream = new ClientSharedConversationRunStreamService();
+ private readonly runStream = new ConversationRunConsumerService();
constructor(private readonly options: ControlPlaneSessionSubscriptionServiceOptions) {}
@@ -77,7 +82,7 @@ export class ControlPlaneSessionSubscriptionService {
});
}
- subscribeToRun(run: ClientSharedConversationRunReference): void {
+ subscribeToRun(run: ControlPlaneConversationRunReference): void {
const sessionAddress = this.sessionAddress;
if (
!sessionAddress
diff --git a/src/cli-v2/state/README.md b/src/cli-v2/state/README.md
index aecf8920..b5276d3d 100644
--- a/src/cli-v2/state/README.md
+++ b/src/cli-v2/state/README.md
@@ -43,8 +43,8 @@ snapshot and call user-intent methods.
control-plane APIs and `client-shared` types/services.
- Attach detailed activity through `sessionRunEvents`; keep `sessionEvents`
limited to lifecycle discovery, approval, queue, and persisted-state signals.
-- Reuse `ClientSharedConversationRunStreamService` for sequence cursors,
- duplicate suppression, gap detection, and reconnect policy.
+- Reuse `ConversationRunConsumerService` through `client-shared` for sequence
+ cursors, duplicate suppression, gap detection, and reconnect policy.
- Do not add a controller that only forwards calls. A controller must own real
workflow behavior: ordering, state transitions, lifecycle reset, event
reduction, or terminal UX coordination.
diff --git a/src/client-shared/services/conversation-run-stream/README.md b/src/client-shared/services/conversation-run-stream/README.md
deleted file mode 100644
index 6f72c732..00000000
--- a/src/client-shared/services/conversation-run-stream/README.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Conversation Run Stream Client State
-
-This service owns frontend-neutral cursor and reconnect semantics for the
-control plane's run-addressed stream.
-
-## Owns
-
-- selecting one accepted run identity;
-- advancing the canonical sequence cursor;
-- suppressing replayed duplicates;
-- detecting sequence gaps;
-- recognizing result, cancellation, and error terminals;
-- bounded exponential reconnect timing from the latest accepted sequence.
-
-## Does not own
-
-- tRPC, React Query, EventSource, timers, or subscription handles;
-- CLI/Ink or browser/React state;
-- conversation activity presentation;
-- server run coordination or replay storage.
-
-CLI-v2 and web-v2 each own their transport lifecycle but must use this service
-for cursor correctness so Heddle's interfaces behave like SDK-built hosts.
diff --git a/src/client-shared/services/conversation-run-stream/conversation-run-stream-service.ts b/src/client-shared/services/conversation-run-stream/conversation-run-stream-service.ts
deleted file mode 100644
index be969675..00000000
--- a/src/client-shared/services/conversation-run-stream/conversation-run-stream-service.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import type { ControlPlaneSessionRunEventEnvelope } from '@/client-shared/api/types.js';
-
-const DEFAULT_MAX_RECONNECT_ATTEMPTS = 6;
-const DEFAULT_RECONNECT_BASE_DELAY_MS = 500;
-const DEFAULT_RECONNECT_MAX_DELAY_MS = 4_000;
-
-export type ClientSharedConversationRunReference = {
- workspaceId: string;
- sessionId: string;
- runId: string;
-};
-
-export type ClientSharedConversationRunSubscriptionInput = ClientSharedConversationRunReference & {
- afterSequence: number;
-};
-
-export type ClientSharedConversationRunRetry = {
- attempt: number;
- delayMs: number;
- input: ClientSharedConversationRunSubscriptionInput;
-};
-
-export type ClientSharedConversationRunEventAcceptance = {
- accepted: boolean;
- terminal: boolean;
-};
-
-/**
- * Owns frontend-neutral run cursor correctness and bounded reconnect policy.
- *
- * Transport adapters remain host-owned. This service prevents CLI and web from
- * diverging on run selection, duplicate suppression, sequence gaps, terminal
- * detection, and replay cursor advancement.
- */
-export class ClientSharedConversationRunStreamService {
- private run?: ClientSharedConversationRunReference;
- private sequence = 0;
- private terminal = false;
- private retryAttempt = 0;
-
- select(run: ClientSharedConversationRunReference): boolean {
- if (this.isCurrent(run)) {
- return false;
- }
-
- this.run = run;
- this.sequence = 0;
- this.terminal = false;
- this.retryAttempt = 0;
- return true;
- }
-
- accept(event: ControlPlaneSessionRunEventEnvelope): ClientSharedConversationRunEventAcceptance {
- if (!this.run || event.runId !== this.run.runId) {
- return { accepted: false, terminal: false };
- }
- if (event.sequence <= this.sequence) {
- return { accepted: false, terminal: this.terminal };
- }
-
- const expectedSequence = this.sequence + 1;
- if (event.sequence !== expectedSequence) {
- throw new Error(
- `Conversation run stream sequence gap for ${event.runId}: expected ${expectedSequence}, received ${event.sequence}.`,
- );
- }
-
- this.sequence = event.sequence;
- this.terminal = event.kind !== 'activity';
- this.retryAttempt = 0;
- return { accepted: true, terminal: this.terminal };
- }
-
- subscriptionInput(): ClientSharedConversationRunSubscriptionInput | undefined {
- if (!this.run || this.terminal) {
- return undefined;
- }
-
- return {
- ...this.run,
- afterSequence: this.sequence,
- };
- }
-
- nextRetry(options: {
- maxAttempts?: number;
- baseDelayMs?: number;
- maxDelayMs?: number;
- } = {}): ClientSharedConversationRunRetry | undefined {
- const input = this.subscriptionInput();
- if (!input) {
- return undefined;
- }
-
- const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS;
- const nextAttempt = this.retryAttempt + 1;
- if (nextAttempt > maxAttempts) {
- return undefined;
- }
-
- this.retryAttempt = nextAttempt;
- const baseDelayMs = options.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS;
- const maxDelayMs = options.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
- return {
- attempt: nextAttempt,
- delayMs: Math.min(baseDelayMs * 2 ** (nextAttempt - 1), maxDelayMs),
- input,
- };
- }
-
- isCurrent(run: ClientSharedConversationRunReference): boolean {
- return Boolean(
- this.run
- && this.run.workspaceId === run.workspaceId
- && this.run.sessionId === run.sessionId
- && this.run.runId === run.runId,
- );
- }
-
- isTerminal(): boolean {
- return this.terminal;
- }
-
- clear(runId?: string): void {
- if (runId !== undefined && this.run?.runId !== runId) {
- return;
- }
-
- this.run = undefined;
- this.sequence = 0;
- this.terminal = false;
- this.retryAttempt = 0;
- }
-}
diff --git a/src/client-shared/services/conversation-run-stream/index.ts b/src/client-shared/services/conversation-run-stream/index.ts
index f625423a..03655ac4 100644
--- a/src/client-shared/services/conversation-run-stream/index.ts
+++ b/src/client-shared/services/conversation-run-stream/index.ts
@@ -1,9 +1,14 @@
+// Presentation clients stay on the client-shared boundary while reusing the
+// exact public remote-run implementation exported by the Heddle SDK.
export {
- ClientSharedConversationRunStreamService,
-} from './conversation-run-stream-service.js';
+ ConversationRunConsumerService,
+ ConversationRunSequenceGapError,
+ ConversationRunTerminalViolationError,
+} from '@/core/chat/remote/index.js';
export type {
- ClientSharedConversationRunEventAcceptance,
- ClientSharedConversationRunReference,
- ClientSharedConversationRunRetry,
- ClientSharedConversationRunSubscriptionInput,
-} from './conversation-run-stream-service.js';
+ ConversationRunConsumerEvent,
+ ConversationRunEventAcceptance,
+ ConversationRunReference,
+ ConversationRunRetry,
+ ConversationRunSubscriptionInput,
+} from '@/core/chat/remote/index.js';
diff --git a/src/core/chat/remote/README.md b/src/core/chat/remote/README.md
new file mode 100644
index 00000000..8653ad53
--- /dev/null
+++ b/src/core/chat/remote/README.md
@@ -0,0 +1,50 @@
+# Remote Conversation Runs
+
+This domain owns browser-safe, transport-neutral correctness for consuming a
+conversation run across a remote boundary.
+
+## Owns
+
+- selecting one accepted run reference;
+- advancing the greatest accepted sequence cursor;
+- suppressing replayed duplicates;
+- rejecting sequence gaps and post-terminal events;
+- recognizing result, cancellation, and error terminals;
+- bounded exponential reconnect timing;
+- runtime validation of the canonical run envelope;
+- JSON-safety validation before a transport serializes an event.
+
+## Does not own
+
+- server run coordination or replay storage (`src/core/chat/runs` owns those);
+- HTTP, SSE, tRPC, WebSocket, React, or transport handles/timers;
+- authentication, authorization, tenant policy, or route design;
+- which activity/result fields are safe to expose publicly;
+- product messages, tool rendering, finalization, conflict handling, or UI.
+
+## Public usage
+
+Import this layer explicitly so the remote-hosting assumption is visible:
+
+```ts
+import {
+ ConversationRunConsumerService,
+ ConversationRunProtocolCodec,
+} from '@roackb2/heddle/remote'
+
+const protocol = new ConversationRunProtocolCodec({
+ activity: PublicActivitySchema,
+ result: PublicResultSchema,
+})
+
+const consumer = new ConversationRunConsumerService({
+ retry: { maxAttempts: 6, baseDelayMs: 500, maxDelayMs: 4_000 },
+})
+```
+
+The host must supply schemas that expose only authorized public payloads. The
+codec owns the envelope and JSON safety; it does not sanitize sensitive product
+or tool data on the host's behalf.
+
+CLI-v2, web-v2, and SDK examples must reuse this service. Do not add another
+cursor/retry state machine in a client adapter.
diff --git a/src/core/chat/remote/consumer-service.ts b/src/core/chat/remote/consumer-service.ts
new file mode 100644
index 00000000..1da27a05
--- /dev/null
+++ b/src/core/chat/remote/consumer-service.ts
@@ -0,0 +1,193 @@
+import type {
+ ConversationRunConsumerEvent,
+ ConversationRunConsumerServiceOptions,
+ ConversationRunEventAcceptance,
+ ConversationRunReference,
+ ConversationRunRetry,
+ ConversationRunSubscriptionInput,
+} from './types.js';
+
+const DEFAULT_MAX_RECONNECT_ATTEMPTS = 6;
+const DEFAULT_RECONNECT_BASE_DELAY_MS = 500;
+const DEFAULT_RECONNECT_MAX_DELAY_MS = 4_000;
+const EVENT_KINDS = new Set(['activity', 'result', 'cancelled', 'error']);
+
+type ResolvedRetryOptions = {
+ maxAttempts: number;
+ baseDelayMs: number;
+ maxDelayMs: number;
+};
+
+export class ConversationRunSequenceGapError extends Error {
+ constructor(
+ readonly runId: string,
+ readonly expectedSequence: number,
+ readonly receivedSequence: number,
+ ) {
+ super(
+ `Conversation run stream sequence gap for ${runId}: expected ${expectedSequence}, received ${receivedSequence}.`,
+ );
+ }
+}
+
+export class ConversationRunTerminalViolationError extends Error {
+ constructor(readonly runId: string, readonly receivedSequence: number) {
+ super(`Conversation run ${runId} received sequence ${receivedSequence} after its terminal event.`);
+ }
+}
+
+/**
+ * Owns transport-neutral cursor correctness and bounded reconnect policy for
+ * one remotely observed conversation run.
+ */
+export class ConversationRunConsumerService<
+ Reference extends ConversationRunReference = ConversationRunReference,
+> {
+ private readonly retry: ResolvedRetryOptions;
+ private run?: Reference;
+ private sequence = 0;
+ private terminal = false;
+ private retryAttempt = 0;
+
+ constructor(options: ConversationRunConsumerServiceOptions = {}) {
+ this.retry = ConversationRunConsumerService.resolveRetryOptions(options.retry);
+ }
+
+ select(run: Reference): boolean {
+ ConversationRunConsumerService.assertRunId(run.runId);
+ if (this.isCurrent(run)) {
+ return false;
+ }
+
+ this.run = run;
+ this.sequence = 0;
+ this.terminal = false;
+ this.retryAttempt = 0;
+ return true;
+ }
+
+ accept(event: ConversationRunConsumerEvent): ConversationRunEventAcceptance {
+ if (!this.run || event.runId !== this.run.runId) {
+ return { accepted: false, terminal: false };
+ }
+
+ ConversationRunConsumerService.assertEvent(event);
+ if (event.sequence <= this.sequence) {
+ return { accepted: false, terminal: this.terminal };
+ }
+ if (this.terminal) {
+ throw new ConversationRunTerminalViolationError(event.runId, event.sequence);
+ }
+
+ const expectedSequence = this.sequence + 1;
+ if (event.sequence !== expectedSequence) {
+ throw new ConversationRunSequenceGapError(event.runId, expectedSequence, event.sequence);
+ }
+
+ this.sequence = event.sequence;
+ this.terminal = event.kind !== 'activity';
+ this.retryAttempt = 0;
+ return { accepted: true, terminal: this.terminal };
+ }
+
+ subscriptionInput(): ConversationRunSubscriptionInput | undefined {
+ if (!this.run || this.terminal) {
+ return undefined;
+ }
+
+ return {
+ ...this.run,
+ afterSequence: this.sequence,
+ };
+ }
+
+ nextRetry(): ConversationRunRetry | undefined {
+ const input = this.subscriptionInput();
+ if (!input) {
+ return undefined;
+ }
+
+ const nextAttempt = this.retryAttempt + 1;
+ if (nextAttempt > this.retry.maxAttempts) {
+ return undefined;
+ }
+
+ this.retryAttempt = nextAttempt;
+ return {
+ attempt: nextAttempt,
+ delayMs: Math.min(
+ this.retry.baseDelayMs * 2 ** (nextAttempt - 1),
+ this.retry.maxDelayMs,
+ ),
+ input,
+ };
+ }
+
+ isCurrent(run: Reference): boolean {
+ return this.run?.runId === run.runId;
+ }
+
+ isTerminal(): boolean {
+ return this.terminal;
+ }
+
+ clear(runId?: string): void {
+ if (runId !== undefined && this.run?.runId !== runId) {
+ return;
+ }
+
+ this.run = undefined;
+ this.sequence = 0;
+ this.terminal = false;
+ this.retryAttempt = 0;
+ }
+
+ private static resolveRetryOptions(
+ options: ConversationRunConsumerServiceOptions['retry'] = {},
+ ): ResolvedRetryOptions {
+ const resolved = {
+ maxAttempts: options.maxAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS,
+ baseDelayMs: options.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS,
+ maxDelayMs: options.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
+ };
+
+ ConversationRunConsumerService.assertNonNegativeSafeInteger(
+ 'Conversation run max reconnect attempts',
+ resolved.maxAttempts,
+ );
+ ConversationRunConsumerService.assertNonNegativeSafeInteger(
+ 'Conversation run reconnect base delay',
+ resolved.baseDelayMs,
+ );
+ ConversationRunConsumerService.assertNonNegativeSafeInteger(
+ 'Conversation run reconnect maximum delay',
+ resolved.maxDelayMs,
+ );
+ if (resolved.maxDelayMs < resolved.baseDelayMs) {
+ throw new Error('Conversation run reconnect maximum delay cannot be less than its base delay.');
+ }
+ return resolved;
+ }
+
+ private static assertEvent(event: ConversationRunConsumerEvent): void {
+ ConversationRunConsumerService.assertRunId(event.runId);
+ if (!Number.isSafeInteger(event.sequence) || event.sequence < 1) {
+ throw new Error('Conversation run event sequence must be a positive safe integer.');
+ }
+ if (!EVENT_KINDS.has(event.kind)) {
+ throw new Error(`Unsupported conversation run event kind: ${String(event.kind)}.`);
+ }
+ }
+
+ private static assertRunId(runId: string): void {
+ if (typeof runId !== 'string' || !runId.trim()) {
+ throw new Error('Conversation run references require a non-empty runId.');
+ }
+ }
+
+ private static assertNonNegativeSafeInteger(label: string, value: number): void {
+ if (!Number.isSafeInteger(value) || value < 0) {
+ throw new Error(`${label} must be a non-negative safe integer.`);
+ }
+ }
+}
diff --git a/src/core/chat/remote/index.ts b/src/core/chat/remote/index.ts
new file mode 100644
index 00000000..1135889f
--- /dev/null
+++ b/src/core/chat/remote/index.ts
@@ -0,0 +1,24 @@
+export {
+ ConversationRunConsumerService,
+ ConversationRunSequenceGapError,
+ ConversationRunTerminalViolationError,
+} from './consumer-service.js';
+export {
+ ConversationRunProtocolCodec,
+ ConversationRunReferenceSchema,
+ ConversationRunReplayCursorSchema,
+} from './protocol-codec.js';
+export type {
+ ConversationRunConsumerEvent,
+ ConversationRunConsumerRetryOptions,
+ ConversationRunConsumerServiceOptions,
+ ConversationRunEventAcceptance,
+ ConversationRunProtocolCodecOptions,
+ ConversationRunProtocolEnvelope,
+ ConversationRunProtocolError,
+ ConversationRunProtocolEvent,
+ ConversationRunProtocolEventKind,
+ ConversationRunReference,
+ ConversationRunRetry,
+ ConversationRunSubscriptionInput,
+} from './types.js';
diff --git a/src/core/chat/remote/protocol-codec.ts b/src/core/chat/remote/protocol-codec.ts
new file mode 100644
index 00000000..e714912f
--- /dev/null
+++ b/src/core/chat/remote/protocol-codec.ts
@@ -0,0 +1,74 @@
+import { z, type ZodType } from 'zod';
+import type {
+ ConversationRunProtocolCodecOptions,
+ ConversationRunProtocolEvent,
+} from './types.js';
+
+const NonBlankStringSchema = z.string().refine((value) => Boolean(value.trim()), {
+ message: 'Expected a non-empty string.',
+});
+
+export const ConversationRunReferenceSchema = z.object({
+ runId: NonBlankStringSchema,
+});
+
+export const ConversationRunReplayCursorSchema = z.number().int().nonnegative().safe();
+
+const ConversationRunProtocolEnvelopeSchema = z.object({
+ runId: NonBlankStringSchema,
+ sequence: z.number().int().positive().safe(),
+ timestamp: z.iso.datetime(),
+});
+
+/**
+ * Owns runtime validation and JSON-safe serialization for the canonical remote
+ * conversation-run envelope. Hosts supply their public activity/result schemas.
+ */
+export class ConversationRunProtocolCodec {
+ readonly eventSchema: ZodType>;
+
+ constructor(options: ConversationRunProtocolCodecOptions) {
+ const schema = z.discriminatedUnion('kind', [
+ ConversationRunProtocolEnvelopeSchema.extend({
+ kind: z.literal('activity'),
+ activity: options.activity,
+ }),
+ ConversationRunProtocolEnvelopeSchema.extend({
+ kind: z.literal('result'),
+ result: options.result,
+ }),
+ ConversationRunProtocolEnvelopeSchema.extend({
+ kind: z.literal('cancelled'),
+ reason: z.string(),
+ }),
+ ConversationRunProtocolEnvelopeSchema.extend({
+ kind: z.literal('error'),
+ error: z.object({
+ code: NonBlankStringSchema,
+ message: z.string(),
+ }),
+ }),
+ ]).superRefine((event, context) => {
+ if (!z.json().safeParse(event).success) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Conversation run events must contain only JSON-safe values.',
+ });
+ }
+ });
+
+ this.eventSchema = schema as ZodType>;
+ }
+
+ parseEvent(input: unknown): ConversationRunProtocolEvent {
+ return this.eventSchema.parse(input);
+ }
+
+ safeParseEvent(input: unknown) {
+ return this.eventSchema.safeParse(input);
+ }
+
+ stringifyEvent(input: unknown): string {
+ return JSON.stringify(this.parseEvent(input));
+ }
+}
diff --git a/src/core/chat/remote/types.ts b/src/core/chat/remote/types.ts
new file mode 100644
index 00000000..cf5ec027
--- /dev/null
+++ b/src/core/chat/remote/types.ts
@@ -0,0 +1,71 @@
+import type { ZodType } from 'zod';
+
+export type ConversationRunReference = {
+ runId: string;
+};
+
+export type ConversationRunProtocolEventKind = 'activity' | 'result' | 'cancelled' | 'error';
+
+export type ConversationRunConsumerEvent = {
+ runId: string;
+ sequence: number;
+ kind: ConversationRunProtocolEventKind;
+};
+
+export type ConversationRunSubscriptionInput =
+ Reference & { afterSequence: number };
+
+export type ConversationRunConsumerRetryOptions = {
+ maxAttempts?: number;
+ baseDelayMs?: number;
+ maxDelayMs?: number;
+};
+
+export type ConversationRunConsumerServiceOptions = {
+ retry?: ConversationRunConsumerRetryOptions;
+};
+
+export type ConversationRunRetry = {
+ attempt: number;
+ delayMs: number;
+ input: ConversationRunSubscriptionInput;
+};
+
+export type ConversationRunEventAcceptance = {
+ accepted: boolean;
+ terminal: boolean;
+};
+
+export type ConversationRunProtocolEnvelope = {
+ runId: string;
+ sequence: number;
+ timestamp: string;
+};
+
+export type ConversationRunProtocolError = {
+ code: string;
+ message: string;
+};
+
+export type ConversationRunProtocolEvent =
+ | (ConversationRunProtocolEnvelope & {
+ kind: 'activity';
+ activity: Activity;
+ })
+ | (ConversationRunProtocolEnvelope & {
+ kind: 'result';
+ result: Result;
+ })
+ | (ConversationRunProtocolEnvelope & {
+ kind: 'cancelled';
+ reason: string;
+ })
+ | (ConversationRunProtocolEnvelope & {
+ kind: 'error';
+ error: ConversationRunProtocolError;
+ });
+
+export type ConversationRunProtocolCodecOptions = {
+ activity: ZodType;
+ result: ZodType;
+};
diff --git a/src/core/chat/runs/README.md b/src/core/chat/runs/README.md
index b9307b3e..940dc5ad 100644
--- a/src/core/chat/runs/README.md
+++ b/src/core/chat/runs/README.md
@@ -25,6 +25,8 @@ service. Do not add a second run coordinator under `src/server` or a product
adapter.
```ts
+import { ConversationRunService } from '@roackb2/heddle/hosted'
+
const runs = new ConversationRunService({
replay: { maxEventsPerRun: 512, retentionMs: 300_000 },
});
diff --git a/src/hosted.ts b/src/hosted.ts
new file mode 100644
index 00000000..838cd8d5
--- /dev/null
+++ b/src/hosted.ts
@@ -0,0 +1,17 @@
+// Public hosted-process entrypoint. This adds no new implementation: it makes
+// the ConversationRunService hosting assumption explicit in package imports.
+export { ConversationRunService } from './core/chat/runs/index.js';
+export type {
+ ConversationRunAccepted,
+ ConversationRunAddress,
+ ConversationRunContext,
+ ConversationRunHandle,
+ ConversationRunReplayOptions,
+ ConversationRunServiceOptions,
+ ConversationRunStreamItem,
+ PendingConversationRunApproval,
+ StartConversationContinueRunInput,
+ StartConversationRunInput,
+ StartConversationTurnRunInput,
+ SubscribeConversationRunInput,
+} from './core/chat/runs/index.js';
diff --git a/src/index.ts b/src/index.ts
index a2c0b177..817af0f5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -13,6 +13,8 @@
// 4. Advanced: lifecycle — drive the engine, sessions, and approvals
// 5. Advanced: storage — back artifacts/credentials with your own store
//
+// Remote-hosting assumptions are explicit peer entrypoints:
+// `@roackb2/heddle/hosted` and `@roackb2/heddle/remote`.
// Lower-level runtime plumbing (LLM adapters, individual tools, trace, memory,
// models, awareness, the agent loop, heartbeat, integrations, utilities) lives
// behind the `@roackb2/heddle/advanced` subpath — see src/advanced.ts.
diff --git a/src/remote.ts b/src/remote.ts
new file mode 100644
index 00000000..64dacd1f
--- /dev/null
+++ b/src/remote.ts
@@ -0,0 +1,3 @@
+// Public browser-safe remote-run entrypoint. Transport/framework adapters live
+// outside this module and depend inward on these contracts and services.
+export * from './core/chat/remote/index.js';
diff --git a/src/web-v2/README.md b/src/web-v2/README.md
index e0ffb946..4338d5a7 100644
--- a/src/web-v2/README.md
+++ b/src/web-v2/README.md
@@ -66,5 +66,6 @@ Web-v2 uses the same accepted-run model exposed by the Heddle SDK:
- cancellation includes the currently observed `runId`.
Run cursor, duplicate, sequence-gap, and reconnect rules belong to
-`ClientSharedConversationRunStreamService`, shared with cli-v2. React hooks own
-only tRPC binding, cache refresh, and browser presentation state.
+`ConversationRunConsumerService` from the public remote-run SDK layer, shared
+with cli-v2 through `client-shared`. React hooks own only tRPC binding, cache
+refresh, and browser presentation state.
diff --git a/src/web-v2/hooks/sessions/useControlPlaneSessionEvents.ts b/src/web-v2/hooks/sessions/useControlPlaneSessionEvents.ts
index 42fc2b1c..3b97b5c5 100644
--- a/src/web-v2/hooks/sessions/useControlPlaneSessionEvents.ts
+++ b/src/web-v2/hooks/sessions/useControlPlaneSessionEvents.ts
@@ -8,8 +8,9 @@ import {
type ControlPlaneSessionRunReference,
} from '@web/api/client';
import {
- ClientSharedConversationRunStreamService,
- type ClientSharedConversationRunSubscriptionInput,
+ ConversationRunConsumerService,
+ type ConversationRunReference,
+ type ConversationRunSubscriptionInput,
} from '@/client-shared/services/conversation-run-stream';
import { ClientSharedSessionActivityService } from '@/client-shared/services/session-activities';
import { ClientSharedNotificationIntentService, type ClientSharedNotificationIntent } from '@/client-shared/services/notifications';
@@ -23,6 +24,10 @@ import type { RefreshControlPlaneSession } from './useControlPlaneSessionLoader'
type SessionRunUpdate = Extract;
type SessionRunTerminal = Exclude;
+type ControlPlaneConversationRunReference = ConversationRunReference & {
+ workspaceId: string;
+ sessionId: string;
+};
type UseControlPlaneSessionEventsArgs = {
workspaceId?: string;
@@ -64,9 +69,13 @@ export function useControlPlaneSessionEvents({
}: UseControlPlaneSessionEventsArgs): ControlPlaneSessionEventsState {
const utils = trpcReact.useUtils();
const [streamConnected, setStreamConnected] = useState(false);
- const [runSubscriptionInput, setRunSubscriptionInput] = useState();
+ const [runSubscriptionInput, setRunSubscriptionInput] = useState<
+ ConversationRunSubscriptionInput
+ >();
const activeAddressRef = useRef({ workspaceId, sessionId });
- const runStreamRef = useRef(new ClientSharedConversationRunStreamService());
+ const runStreamRef = useRef(
+ new ConversationRunConsumerService(),
+ );
const reconnectTimerRef = useRef | undefined>(undefined);
const invalidateWorkspaceDiff = useCallback(() => {
void utils.controlPlane.workspaceChanges.invalidate(workspaceId ? { workspaceId } : undefined);