Runnable hosted boundary
Keep one run service alive with your host
Add `ConversationRunService` when request and subscription lifetimes differ, a client may reconnect, or a turn needs an independently addressable cancel and approval lifecycle.
Customization depth
Advanced lifecycle
Hosting depth
Long-lived Node process
Documentation status
Runnable reference
Assumptions
- One host process can own an active run for its lifetime.
- Authentication happens before your application constructs a run address.
- Your product can route later subscribe, cancel, and approval operations to the same run owner.
- One active run per host-defined address, stable run IDs, ordered activity, one terminal, cancellation, approvals, and bounded replay.
- Authoritative cancellation even when a late executor ignores its abort signal.
- Awaited result projection and safe public error projection before terminal publication.
- Authenticated account or tenant scope and a stable conversation ID.
- Engine construction, credentials, tools, storage adapters, approval decisions, telemetry, and process lifetime.
- Authorization of every retained run lookup and routing in multi-process deployments.
Install and import
The hosted surface is an explicit server-process assumption. It does not add an HTTP framework.
npm install @roackb2/heddleimport { ConversationRunCancelledError, createConversationEngine } from '@roackb2/heddle'
import { ConversationRunService } from '@roackb2/heddle/hosted'Construct it once
Create one service at the same lifecycle boundary as your server or worker. The address can contain product scope, but it must always contain a stable sessionId.
type ProductRunAddress = {
accountId: string
sessionId: string
}
const runs = new ConversationRunService<ProductRunAddress>({
addressKey: ({ accountId, sessionId }) =>
JSON.stringify([accountId, sessionId]),
replay: { maxEventsPerRun: 512, retentionMs: 300_000 },
})Do not instantiate this service once per request. Doing so discards active-run discovery and reconnect replay, and allows separate requests to disagree about which run is active.
Start a persisted turn
Resolve or create the Heddle session through the engine, then start the run. Use a stable product conversation ID so the second prompt continues the same conversation.
const engine = createConversationEngine({
workspaceRoot,
stateRoot,
model: 'gpt-5.4',
})
const session =
(await engine.sessions.readExisting(productConversationId)) ??
(await engine.sessions.create({ id: productConversationId }))
const run = runs.startTurn({
address: { accountId, sessionId: session.id },
engine,
turn: { sessionId: session.id, prompt, host },
projectResult: async (result, context) => {
context.controller.signal.throwIfAborted()
await applyAuthorizedProductResult(result)
return { outcome: result.outcome, summary: result.summary }
},
projectError: () => ({
code: 'agent_failed',
message: 'The agent could not complete this request.',
}),
})
return { accepted: true, runId: run.runId, acceptedAt: run.acceptedAt }projectResult is a transaction boundary. Heddle waits for it before publishing a successful terminal, so product persistence or reconciliation cannot race behind visible success. If projection fails, the run fails. projectError is the symmetric safety boundary: the host keeps the original failure for diagnostics while subscribers receive only the approved code and message.
Subscribe, replay, and cancel
for await (const event of run.events({ afterSequence, signal })) {
await sendToSubscriber(event)
}
const result = await run.resultCancellation is a separate terminal path. cancel() is authoritative, and the result promise rejects with ConversationRunCancelledError:
run.cancel()
try {
await run.result
} catch (error) {
if (!(error instanceof ConversationRunCancelledError)) throw error
}Every event has a runId, monotonically increasing sequence, and timestamp. The stream emits activities followed by exactly one result, cancelled, or error terminal. Aborting run.events(...) disconnects only that subscriber. It does not cancel the run.
A handle's cancel() and resolveApproval(...) remain bound to that exact run. A stale handle cannot affect a later run at the same address.
Authorize retained handles
A later request can look up a retained run, but possession of its ID is never sufficient authorization:
const retained = runs.getRetainedRun(runId)
if (!retained || retained.accountId !== authenticatedAccountId) {
throw new Error('Run not found')
}Returning not-found for the wrong scope avoids leaking another tenant's run existence. Do not maintain a second host run registry solely for authorization; the retained handle already includes the original host-defined address.
Limits you must design around
- Replay and retained handles live in memory and are bounded. The defaults are 512 events per run and five minutes of retained history.
- Durable final conversation state remains in the conversation repository, not the replay buffer.
- The service does not provide restart recovery or cross-instance delivery.
- A multi-process host must route operations to the owning process or add shared delivery infrastructure.
- The
HostedAgentServiceclass in the example is application code that demonstrates composition. It is not exported by Heddle and should not become a required wrapper in your product.