Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,9 @@ 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
- `@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
- `@roackb2/heddle/hosted/http-sse`: optional Node HTTP/SSE framing, cursor, backpressure, and subscriber-disconnect correctness without choosing a server framework
- `@roackb2/heddle-remote`: independently installable runtime-validated run envelopes plus shared cursor, duplicate, gap, terminal, and reconnect correctness for remote clients
- `@roackb2/heddle-remote/http-sse`: optional browser-safe fetch/SSE transport for the conventional REST run resource
- `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.
Expand Down
4 changes: 4 additions & 0 deletions docs/guides/programmatic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ hosting assumptions.
product host needs to build an agentic experience.
- `@roackb2/heddle/hosted` — process-local run identity, replay, cancellation,
and approvals for a long-lived host process.
- `@roackb2/heddle/hosted/http-sse` — opt-in Node HTTP/SSE framing,
backpressure, replay-cursor, and subscriber-disconnect correctness.
- `@roackb2/heddle-remote` — an independently installable, browser-safe package
with runtime contracts plus transport-neutral cursor, duplicate, gap,
terminal, and reconnect correctness.
- `@roackb2/heddle-remote/http-sse` — opt-in browser-safe fetch/SSE parsing and
transport validation for the conventional REST run resource.
- `@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,
Expand Down
28 changes: 18 additions & 10 deletions docs/guides/programmatic/integration-layers.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ in control.
HOST-OWNED PRODUCT
UI state and rendering
|
@roackb2/heddle-remote consumer + public contract
@roackb2/heddle-remote consumer + optional HTTP/SSE client
|
transport client/server adapter optional
host transport adapter + optional Node SSE helper
|
application service and composition root
(product session IDs, tools, config, repositories, policy)
Expand Down Expand Up @@ -47,7 +47,7 @@ not transport infrastructure.
| 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 |
| Transport/API | Optional fetch/SSE client and Node SSE streaming correctness when that preset is selected | Framework, routes/procedures, wire schemas, auth, errors, limits, CORS, and rate limiting |
| 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
Expand All @@ -63,9 +63,9 @@ disconnect as implicit cancellation.
| 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 | `@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) |
| Express with REST + SSE | Core plus `@roackb2/heddle/hosted/http-sse` | [`05-hosted-agent/02-http-sse-api`](../../../examples/sdk/05-hosted-agent/02-http-sse-api) |
| 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) |
| A browser using the conventional REST/SSE contract | `@roackb2/heddle-remote/http-sse` | [`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
Expand Down Expand Up @@ -108,7 +108,9 @@ 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
This base layer does not own HTTP, SSE, tRPC, timers, auth, or UI state. Add
`@roackb2/heddle-remote/http-sse` only when the host uses the conventional REST
run resource and authenticated streaming `fetch`. See
[Remote conversation runs](remote-runs.md).

### Transport adapter
Expand All @@ -118,9 +120,13 @@ 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.
For Node HTTP/SSE, `@roackb2/heddle/hosted/http-sse` owns strict replay cursor
parsing, canonical frames, backpressure, and subscriber disconnect cleanup.
The host still owns route registration, authentication/authorization, public
schemas, JSON error policy, CORS, and limits. Express and other Node frameworks
can share the helper because it targets `IncomingMessage` and `ServerResponse`.
tRPC, WebSocket, native bridges, and other transports stay on the neutral base
layer.

### Client protocol and UI

Expand All @@ -142,7 +148,9 @@ normal client architecture.
| 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 |
| Conventional Node HTTP/SSE | `@roackb2/heddle/hosted/http-sse` plus host routes and policy |
| Conventional browser REST/SSE | `@roackb2/heddle-remote/http-sse` plus host schemas and headers |
| tRPC, 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 |

Expand Down
51 changes: 48 additions & 3 deletions docs/guides/programmatic/remote-runs.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ import {
ConversationRunConsumerService,
ConversationRunProtocolCodec,
} from '@roackb2/heddle-remote'
import { ConversationRunHttpSseClient } from '@roackb2/heddle-remote/http-sse'
```

- `@roackb2/heddle` owns persisted conversation semantics.
- `@roackb2/heddle/hosted` owns process-local active-run coordination.
- `@roackb2/heddle-remote` is independently installable and owns client cursor
correctness plus runtime wire validation without choosing HTTP, SSE, tRPC,
WebSocket, React, or auth.
correctness plus runtime wire validation without choosing a transport.
- `@roackb2/heddle-remote/http-sse` is an optional assumption layer for the
conventional REST run resource and streaming `fetch`; it still does not
choose React or auth policy.

## Define the public wire payload

Expand Down Expand Up @@ -139,6 +142,40 @@ 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.

## Opt into the REST/SSE preset

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:

```ts
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.

## Server-side run ownership

Use one host-long-lived `ConversationRunService` from the hosted entrypoint:
Expand All @@ -159,10 +196,18 @@ 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.

## What remains host-owned

- start/cancel routes or procedures;
- HTTP/SSE/tRPC/WebSocket adapters;
- 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;
Expand Down
5 changes: 3 additions & 2 deletions examples/sdk/05-hosted-agent/02-http-sse-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ for curl commands, lifecycle details, and production replacements.

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.
2. [`http-api.ts`](http-api.ts) — authenticated start/cancel handlers plus
host-owned routes composed with `@roackb2/heddle/hosted/http-sse` for cursor,
framing, backpressure, and disconnect correctness.
3. [`server.ts`](server.ts) — runnable local composition with deliberately
non-production auth.

Expand Down
6 changes: 4 additions & 2 deletions examples/sdk/05-hosted-agent/02-http-sse-api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ export const HostedAgentApiErrorSchema = z.object({

export type StartHostedAgentRunInput = z.infer<typeof StartHostedAgentRunInputSchema>;
export type StartHostedAgentRunResult = z.infer<typeof StartHostedAgentRunResultSchema>;
export type HostedAgentActivity = z.infer<typeof HostedAgentActivitySchema>;
export type HostedAgentResult = z.infer<typeof HostedAgentResultSchema>;
export type HostedAgentRunEvent = ConversationRunProtocolEvent<
z.infer<typeof HostedAgentActivitySchema>,
z.infer<typeof HostedAgentResultSchema>
HostedAgentActivity,
HostedAgentResult
>;
export type CancelHostedAgentRunResult = z.infer<typeof CancelHostedAgentRunResultSchema>;
88 changes: 22 additions & 66 deletions examples/sdk/05-hosted-agent/02-http-sse-api/http-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
* and deployment. Reimplement this adapter in the host's existing framework
* when Express/SSE is not already part of the stack.
*/
import { once } from 'node:events';
import {
Router,
type Request,
Expand All @@ -14,6 +13,11 @@ import {
} from 'express';
import { z, ZodError } from 'zod';
import { ConversationRunConflictError } from '../../../../src/hosted.js';
import {
ConversationRunSseReplayCursorError,
parseConversationRunSseReplayCursor,
streamConversationRunSse,
} from '../../../../src/hosted/http-sse.js';
import {
HostedAgentInputError,
HostedAgentRunNotFoundError,
Expand All @@ -23,20 +27,14 @@ import {
CancelHostedAgentRunResultSchema,
HostedAgentApiErrorSchema,
HostedAgentRunProtocol,
HostedAgentRunEventSchema,
StartHostedAgentRunInputSchema,
StartHostedAgentRunResultSchema,
type HostedAgentRunEvent,
} from './contracts.js';

const AuthenticatedAccountSchema = z.object({
accountId: z.string().trim().min(1),
});
const RunIdSchema = z.string().trim().min(1);
const ReplayCursorSchema = z.string()
.regex(/^(0|[1-9]\d*)$/, 'Replay cursor must be a non-negative integer.')
.transform(Number)
.refine(Number.isSafeInteger, 'Replay cursor must be a safe integer.');

export type HostedAgentApiDeps = {
agent: HostedAgentService;
Expand Down Expand Up @@ -77,40 +75,30 @@ export function createStartHostedAgentRunHandler(deps: HostedAgentApiDeps): Requ

export function createSubscribeHostedAgentRunHandler(deps: HostedAgentApiDeps): RequestHandler {
return async (request, response) => {
const subscription = new AbortController();
const abortSubscription = () => subscription.abort();
request.once('aborted', abortSubscription);
response.once('close', abortSubscription);

try {
const { accountId } = await authenticate(deps, request);
const runId = RunIdSchema.parse(request.params.runId);
const afterSequence = parseReplayCursor(request);
const events = deps.agent.subscribe({
accountId,
runId,
afterSequence,
signal: subscription.signal,
const afterSequence = parseConversationRunSseReplayCursor({
query: request.query.after,
lastEventId: request.header('Last-Event-ID'),
});
await streamConversationRunSse({
request,
response,
protocol: HostedAgentRunProtocol,
subscribe: (signal) => deps.agent.subscribe({
accountId,
runId,
afterSequence,
signal,
}),
});

setSseHeaders(response);
for await (const event of events) {
await writeSseEvent(response, HostedAgentRunEventSchema.parse(event), subscription.signal);
}
endResponse(response);
} catch (error) {
if (subscription.signal.aborted) {
return;
}
if (!response.headersSent) {
sendRequestError(deps, response, error);
return;
}
deps.onError?.(error);
endResponse(response);
} finally {
request.off('aborted', abortSubscription);
response.off('close', abortSubscription);
}
};
}
Expand All @@ -133,40 +121,6 @@ async function authenticate(deps: HostedAgentApiDeps, request: Request): Promise
return AuthenticatedAccountSchema.parse(await deps.authenticate(request));
}

function parseReplayCursor(request: Request): number | undefined {
// An explicit query cursor wins over Last-Event-ID so fetch clients can
// deliberately choose their checkpoint; native EventSource uses the header.
const value = request.query.after ?? request.header('Last-Event-ID');
return value === undefined ? undefined : ReplayCursorSchema.parse(value);
}

function setSseHeaders(response: Response): void {
response.status(200);
response.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
response.setHeader('Cache-Control', 'no-cache, no-transform');
response.setHeader('Connection', 'keep-alive');
response.setHeader('X-Accel-Buffering', 'no');
response.flushHeaders?.();
}

async function writeSseEvent(
response: Response,
event: HostedAgentRunEvent,
signal: AbortSignal,
): Promise<void> {
const frame = `event: ${event.kind}\nid: ${event.sequence}\ndata: ${HostedAgentRunProtocol.stringifyEvent(event)}\n\n`;
if (response.write(frame)) {
return;
}
await once(response, 'drain', { signal });
}

function endResponse(response: Response): void {
if (!response.destroyed && !response.writableEnded) {
response.end();
}
}

function sendRequestError(deps: HostedAgentApiDeps, response: Response, error: unknown): void {
const apiError = toApiError(error);
if (apiError.status >= 500) {
Expand All @@ -190,7 +144,9 @@ function toApiError(error: unknown): HostedAgentApiError {
if (error instanceof ConversationRunConflictError) {
return new HostedAgentApiError(409, 'run_conflict', error.message);
}
if (error instanceof HostedAgentInputError || error instanceof ZodError) {
if (error instanceof HostedAgentInputError
|| error instanceof ConversationRunSseReplayCursorError
|| error instanceof ZodError) {
return new HostedAgentApiError(400, 'invalid_request', 'Request validation failed.');
}
return new HostedAgentApiError(500, 'internal_error', 'The hosted agent request failed.');
Expand Down
6 changes: 4 additions & 2 deletions examples/sdk/05-hosted-agent/03-browser-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ for the runnable reconnect/cancel flow.

## Read in this order

1. [`browser-client.ts`](browser-client.ts) — URL/auth/error/SSE parsing, schema
validation, cursor validation, and abort propagation.
1. [`browser-client.ts`](browser-client.ts) — host-owned public schemas and
protocol configured for `ConversationRunHttpSseClient`; URL, HTTP error,
incremental SSE parsing, identity checks, cursor validation, and abort
cleanup come from `@roackb2/heddle-remote/http-sse`.
2. [`run.ts`](run.ts) — Heddle's public remote consumer composed with
application-owned transport timers, terminal rendering, and cancel policy.

Expand Down
Loading
Loading