# `@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.

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

```ts
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](/docs/harness.md). 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.

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

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

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

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

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

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

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

```ts
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](https://github.com/ZephyrCloudIO/ze-harness/blob/main/packages/server/README.md#theme).

## 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`

---

Generated from [`packages/worker-host/README.md`](https://github.com/ZephyrCloudIO/ze-harness/blob/main/packages/worker-host/README.md).
