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.
Heddle owns
  • 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.
Your product owns
  • 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.

Shell
npm install @roackb2/heddle
TypeScript
import { 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.

TypeScript
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.

TypeScript
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

TypeScript
for await (const event of run.events({ afterSequence, signal })) {
  await sendToSubscriber(event)
}

const result = await run.result

Cancellation is a separate terminal path. cancel() is authoritative, and the result promise rejects with ConversationRunCancelledError:

TypeScript
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:

TypeScript
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 HostedAgentService class 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.

Canonical sources