@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
AppendConversationCommandAuthorIdClientIdConversationEntryConflictConversationEntryIdDurableContentResolutionErrorDurableContentResolverHistoryVisibilityInvalidServerCursorKnownTurnFaultCodeModelCallIdentityModelEventModelFailureModelRefModelRequestModelsDevSourceErrorModelServiceModelTurnContextModelUsageNormalizedModelRecordNormalizedModelsDevSnapshotNormalizedProviderRecordPermissionRequestPermissionRequestIdPermissionRequestNotFoundPromptAttributionPromptConflictPromptContentProviderConfigEntryProviderConfigMapProviderCredentialResolutionProviderCredentialResolverProviderCredentialScopeProviderDefinitionProviderDefinitionMapProviderModelAcquisitionInputPublicPromptAdmissionQuestionRequestQuestionRequestIdQuestionRequestNotFoundReplyPermissionInputReplyQuestionInputServerActivityCursorServerNameServerStreamEventSessionActivityCursorSessionActivityPageSessionConflictSessionIdSessionNotFoundSessionServicesSessionStreamEventSessionTitleSessionTurnStateSteerIdSteerRejectedStorageErrorStorageFailureSubmitPromptCommandToolApprovalSnapshotToolDispatchErrorToolDispatchLeaseToolDispatchLifecycleToolDispatchOutcomeToolQuestionSnapshotTurnEventTurnEventsPageTurnFaultTurnIdTurnNotFoundTurnOutcomeTurnPartTurnPhaseTurnSnapshotTurnStreamEventTurnTextPartTurnToolPartUnavailableErrorWatchSessionActivityInputWatchTurnEventWorkerHarnessHostWorkerHarnessHostOptionsWorkerModelCatalogWorkerModelCatalogStatusWorkerServerPluginWorkerServerPluginConflictWorkerServerPluginRejectedWorkerServerPluginStatusWorkerStaticSessionPlugin