@zephyrcloudio/harness-worker-host

The embeddable Harness host for Durable Objects. This is the only Ze Harness package embedding hosts install: every other workspace package is bundled into dist at build time.

import { WorkerHarnessHost } from '@zephyrcloudio/harness-worker-host';

Embedding

One Durable Object instance shares one host-provided Workspace (file home plus Computer backend) across N HarnessSessions on one SQLite database, with injected providerDefinitions (embedder-implemented providers, selected per turn), contentResolver (attachment references, resolved only after visibility filtering), and toolDispatch (host custody after Harness permission resolution). Credentials arrive only through injected ports; ambient discovery stays disabled.

const host = new WorkerHarnessHost({
  server: ServerName.make('embedded'),
  state: ctx,
  workspace,
  revisionId: 'host-revision-1',
  model: ModelRef.make('example/model'),
  providerDefinitions: { example: exampleProvider },
  contentResolver: hostContentResolver,
  toolDispatch: hostToolDispatch,
});

model is the optional room default. Without it, prompts that name no model and title generation use the catalog's first available model; a per-turn model on the prompt request always wins, and the admitting turn's model also titles the session.

One multiplexed DO alarm serves Harness recovery, Computer sync, and host-owned participants (registerAlarmParticipant + setParticipantDue). Direct SessionServices admissions call notifyAdmission() so the drain runs via waitUntil. Harness never closes the provided Workspace; the owning DO retains its lifetime.

Hybrid CodeMode

Agents can combine tool calls using CodeMode. It is on by default and needs no additional dependency. To turn it off, pass codemode: { enabled: false } to WorkerHarnessHost. Interrupted scripts are not automatically replayed after a restart.

Watching a Turn

host.watchTurn(sessionId, turnId, { afterRevision }) returns the live events of one Turn as an Effect Stream<TurnStreamEvent, UnavailableError>, exactly as the /turns/:turnId/events SSE route streams them but with no HTTP request or SSE parsing: durable rows after the cursor, synthesized deltas, heartbeats, and one terminal event after which the stream ends. Omit afterRevision to receive only events after the current revision; pass the last revision you saw to resume without a gap.

import { Effect, Stream } from 'effect';

const events = await host.watchTurn(sessionId, turnId, { afterRevision });
await Effect.runPromise(Stream.runForEach(events, (event) => Effect.sync(() => render(event))));

Watching a Session or the server

host.watchSession(sessionId, { afterActivityCursor }) returns the Session's activity as a Stream<SessionStreamEvent, UnavailableError> and host.watchServer({ afterServerCursor }) returns the whole server's activity (Session creations, titles, deletions, and every Turn change on any Session) as a Stream<ServerStreamEvent, UnavailableError>, exactly as the /sessions/:sessionId/events and /events SSE routes stream them. Omitting the Session cursor replays that Session's history. Omitting the server cursor starts live: the first event is a server.cursor naming the current cursor, so list Sessions and pending requests after it and nothing is missed; pass the last cursor you saw to replay every stamped row after it. Neither stream ends on its own and neither admits work or arms recovery: interrupt the stream to stop watching. A session.created event is an upsert by session.id, since a watch that starts after an existing Session was retitled sees it again.

const activity = await host.watchServer(); // live; resume later with { afterServerCursor }
await Effect.runPromise(
  Stream.runForEach(activity, (event) =>
    Effect.sync(() => {
      if (event.type === 'session.created') upsertSession(event.session);
    }),
  ),
);

host.fetch also serves the server-level routes (the Session list, the activity stream, and server deletion), so an in-process client built on it reaches them without a gateway.

Unknown provider ids

providers is an allowlist compiled against the host's catalog (the bundled snapshot, or a newer modelCatalog, below). By default (unknownProviders: 'fail') an id with no catalog entry and no complete definition fails host construction, and therefore every session on the object. Pass unknownProviders: 'report' to drop such ids instead: the host constructs with the remaining providers and reports what it dropped on resources(), so the embedder learns about every rejected id rather than finding out at the first turn.

const host = new WorkerHarnessHost({ ...options, providers, unknownProviders: 'report' });
const { providerIds, rejectedProviders } = await host.resources();
for (const { providerId, message } of rejectedProviders) {
  console.warn(`provider ${providerId} dropped: ${message}`);
}

rejectedProviders is empty under 'fail'. Diagnostics name only the provider id, never a credential.

Runtime provider credentials

Injected-only hosts (credentialMode: 'injected-only') write one provider's key at a time, at any point in the host's life. Turns read the live key at acquire time, so a write or rotation takes effect on the next turn with no reload; removals fail new acquires closed while in-flight attempts keep the key they acquired.

await host.setProviderCredential('example', apiKey);
const live = await host.hasProviderCredential('example');
await host.removeProviderCredential('example');

Keys live in memory only: never persisted, never logged, cleared on restart. Presence is boolean-only; the key is never exposed. Unknown provider ids throw on write and remove (has answers false). These methods throw on non-injected hosts, where the live port mirrors durable storage instead.

Per-turn credentials

The slot holds one key per provider for the whole object. When several callers share one host and each should run on their own key, give the host a resolver instead of writing the slot per admission. The harness calls it on every model acquisition that serves session work, naming that work, and uses the answer for that acquisition only:

const host = new WorkerHarnessHost({
  // ...
  credentialMode: 'injected-only',
  resolveProviderCredential: async ({ providerId, scope }) => {
    // scope.kind is 'turn' | 'compaction' | 'detached'; every scope carries sessionId,
    // a turn carries turnId, a compaction carries compactionId, and a detached title
    // call carries the admitting turnId when one scheduled it.
    const owner = await lookupOwner(scope);
    if (owner === undefined) return undefined; // fall through to the slot
    const supplied = await custody.supply({ providerId, owner });
    return supplied.ok
      ? { apiKey: supplied.apiKey }
      : { denied: supplied.message, code: supplied.code };
  },
});

Precedence at acquire time is fixed in Core (packages/harness/src/plugin/setup.ts): the frozen config key, the provider's OAuth connection (only without a config key), the live port, then the environment. credentialMode: 'injected-only' drops the config key and the environment, so on this host the live port is the only source, and the live port asks the resolver first and falls through to the slot when it returns undefined or a blank key.

A Turn acquires its model once and reuses it for the tool loop, in-turn compaction, and steering continuations, so the resolver runs once per Turn. A Turn still pending across a restart asks again when it starts, so a revoked or rotated key takes effect at that point and nothing about the binding is persisted by the harness. { denied } fails the acquire with ModelAuthenticationFailure; on a Turn that surfaces as TurnFault.code === 'model_run_failed' with the denial text as the message, so the text must name no secret. code becomes the fault's providerCode, for embedders that project fault codes and drop messages; it must match [A-Za-z0-9][A-Za-z0-9._:/-]* and be at most 128 characters, and a code outside that shape is dropped while the denial still surfaces.

Plugin models that delegate to a sibling (the Auto lanes) pick their candidate siblings from the host-wide sources only, so which providers qualify is the same for every caller; the chosen sibling is then acquired through the resolver with the Turn's scope, like a native model. A resolver-only host that uses such plugins must still hold a slot or environment key for each provider they may delegate to, or no sibling qualifies and the Turn fails with "No configured sibling model is available". Scope-free reads (provider discovery, hasProviderCredential) and every write go to the slot and never call the resolver. Resolver exceptions fail the acquire the same way with a diagnostic that names only the provider.

Provider catalog

The bundle exports the harness's provider catalog so embedders can build their providers allowlist and model pickers from the same data the host compiles against instead of hardcoding ids. loadBundledModelsDevSnapshot() returns the models.dev snapshot copied into the harness at build time (providers is keyed by provider id; each record carries displayName, api, and models), and defaultProviderIds names the ids a host enables when providers is omitted. What this host actually compiled, after providers, providerDefinitions, and enabled filtering, is (await host.resources()).providerIds.

import {
  loadBundledModelsDevSnapshot,
  defaultProviderIds,
} from '@zephyrcloudio/harness-worker-host';

const { providers } = loadBundledModelsDevSnapshot();
const choices = Object.values(providers).map(({ id, displayName, models }) => ({
  id,
  displayName,
  modelIds: Object.keys(models),
}));
const compiled = (await host.resources()).providerIds; // ids live on this host
void defaultProviderIds; // ['openai', 'opencode-go', 'openrouter']

The snapshot is a point-in-time copy of models.dev bundled into the package, not a live feed; a newer harness release carries a newer snapshot, and modelCatalog (below) catches a host up without one. Type-only exports NormalizedModelsDevSnapshot, NormalizedProviderRecord, NormalizedModelRecord, ProviderConfigMap, and ProviderConfigEntry describe the snapshot and the providers option.

Published catalog

Pass modelCatalog: { document, publishedAt } to compile from a catalog you fetched instead of the bundled snapshot. document is the parsed JSON of a models.dev-format catalog, the same document the Node daemon refreshes from ZE_HARNESS_MODELS_URL (default https://models.dev/api.json), passed unmodified: the host normalizes it exactly as the Node refresh does, keeping providers and per-model packages that map to a native protocol. publishedAt is when that document was published, in epoch milliseconds: the response's Last-Modified (R2 sets it at upload). Keep it with your cached copy; a 304 does not change it, and when you fetched the document does not count.

The bundled snapshot is the floor, as on the Node host. The supplied catalog is used only when it is valid and publishedAt is at or after the bundled snapshot's build time, so a newer release is never dragged back by a stale published catalog; otherwise the host uses the bundled snapshot. An invalid or older catalog never fails host construction: the host logs a warning and reports which catalog it compiled on resources().modelCatalog (source is 'supplied' or 'bundled', with contentRevision, publishedAt (the bundled snapshot's build time when it is the source), and a diagnostic when a supplied catalog was not used). The catalog is read once, when the host first starts; a newer catalog takes effect on the next host construction, so refresh it on your own schedule and pass the latest copy when the object starts. A response without Last-Modified proves no publish time; pass a publishedAt only when your source states one.

Fetching and caching are the embedder's: use conditional requests (If-None-Match) and keep the last good document. validateModelCatalog({ document, publishedAt }) applies the host's normalization without starting a host, so a degraded upstream response can be refused before it replaces your cached copy; it rejects with ModelsDevSourceError naming the problem. The models.dev document is several megabytes, more than one Durable Object storage value holds, so keep the cached copy somewhere sized for it (R2, Workers KV, or the Cache API). Normalizing it costs CPU on each host start, the same work the Node refresh does once per fetch.

import { validateModelCatalog, WorkerHarnessHost } from '@zephyrcloudio/harness-worker-host';

const response = await fetch(catalogUrl, {
  headers: cached?.etag === undefined ? {} : { 'If-None-Match': cached.etag },
});
const lastModified = Date.parse(response.headers.get('last-modified') ?? '');
if (response.ok && Number.isFinite(lastModified)) {
  const fetched = { document: await response.json(), publishedAt: lastModified };
  await validateModelCatalog(fetched); // rejects: keep the previous copy
  cached = { ...fetched, etag: response.headers.get('etag') ?? undefined };
} // a 304 keeps `cached` exactly as stored
const host = new WorkerHarnessHost({
  ...options,
  ...(cached === undefined
    ? undefined
    : { modelCatalog: { document: cached.document, publishedAt: cached.publishedAt } }),
});
const { modelCatalog } = await host.resources();
if (modelCatalog.diagnostic !== undefined) console.warn(modelCatalog.diagnostic);

Dashboard

Pass dashboard: true and host.fetch serves the Harness dashboard at GET /dashboard, filled in with the host's server. It is the same single HTML page the Node daemon serves. The page sends document-relative v1/servers/<server>/… requests, so mount it under any prefix that forwards GETs to host.fetch with the prefix stripped:

const host = new WorkerHarnessHost({ server, state: ctx, workspace, revisionId, dashboard: true });

// In the embedder's proxy: <prefix>/dashboard and <prefix>/v1/... reach the host as /dashboard and /v1/...
const response = await host.fetch(new Request(new URL(path, 'https://harness.local'), request));

host.fetch trusts its caller, so the page never asks for a token, and it hides the log panel because this host has no v1/logs. The response allows framing (no frame-ancestors) and limits API calls to connect-src 'self', so the page and its API share one origin; a custom-scheme page needs a scheme with a real (non-opaque) origin. An embedding parent can restyle the page with frame.contentWindow.postMessage({ type: 'ze-harness:theme', tokens: { '--zh-bg': '#fff' } }, '*'); the token names are listed in the harness-server README.

Peers and versions

The only runtime peers are effect and @cloudflare/computer, pinned exactly to this repo's catalog. They must be singletons shared with the host worker: duplicate effect breaks context tags and layers, and a duplicate Computer Workspace breaks file-home identity. The bundle has no top-level cloudflare:workers import, so it loads in plain Node (unit tests, tooling) without the cloudflare: scheme; platform APIs are only required when the host runs inside a Durable Object. Pin this package exactly; all Ze Harness packages share one version and pack:smoke enforces it.

Public TypeScript exports

  • AppendConversationCommand
  • AuthorId
  • ClientId
  • ConversationEntryConflict
  • ConversationEntryId
  • DurableContentResolutionError
  • DurableContentResolver
  • HistoryVisibility
  • InvalidServerCursor
  • KnownTurnFaultCode
  • ModelCallIdentity
  • ModelEvent
  • ModelFailure
  • ModelRef
  • ModelRequest
  • ModelsDevSourceError
  • ModelService
  • ModelTurnContext
  • ModelUsage
  • NormalizedModelRecord
  • NormalizedModelsDevSnapshot
  • NormalizedProviderRecord
  • PermissionRequest
  • PermissionRequestId
  • PermissionRequestNotFound
  • PromptAttribution
  • PromptConflict
  • PromptContent
  • ProviderConfigEntry
  • ProviderConfigMap
  • ProviderCredentialResolution
  • ProviderCredentialResolver
  • ProviderCredentialScope
  • ProviderDefinition
  • ProviderDefinitionMap
  • ProviderModelAcquisitionInput
  • PublicPromptAdmission
  • QuestionRequest
  • QuestionRequestId
  • QuestionRequestNotFound
  • ReplyPermissionInput
  • ReplyQuestionInput
  • ServerActivityCursor
  • ServerName
  • ServerStreamEvent
  • SessionActivityCursor
  • SessionActivityPage
  • SessionConflict
  • SessionId
  • SessionNotFound
  • SessionServices
  • SessionStreamEvent
  • SessionTitle
  • SessionTurnState
  • SteerId
  • SteerRejected
  • StorageError
  • StorageFailure
  • SubmitPromptCommand
  • ToolApprovalSnapshot
  • ToolDispatchError
  • ToolDispatchLease
  • ToolDispatchLifecycle
  • ToolDispatchOutcome
  • ToolQuestionSnapshot
  • TurnEvent
  • TurnEventsPage
  • TurnFault
  • TurnId
  • TurnNotFound
  • TurnOutcome
  • TurnPart
  • TurnPhase
  • TurnSnapshot
  • TurnStreamEvent
  • TurnTextPart
  • TurnToolPart
  • UnavailableError
  • WatchSessionActivityInput
  • WatchTurnEvent
  • WorkerHarnessHost
  • WorkerHarnessHostOptions
  • WorkerModelCatalog
  • WorkerModelCatalogStatus
  • WorkerServerPlugin
  • WorkerServerPluginConflict
  • WorkerServerPluginRejected
  • WorkerServerPluginStatus
  • WorkerStaticSessionPlugin