# bind

**`@juno-ai/bind` — an agent harness.**

The agent loop is a bind chain: each turn sequences a model completion into
tool effects into the next turn's context. `bind` is the harness that runs that
chain — the runtime-agnostic core of a production agent loop, extracted from
[Monad](https://onmonad.ai).

This document is organised on the [Diátaxis](https://diataxis.fr) axes.
**Tutorial** and **How-to** are practical; **Reference** and **Explanation** are
theoretical. If you are a coding agent working against this package, read the
exported types for signatures — they are the specification — and use
[Reference](#reference) for the contracts those types cannot express,
[Usage scenarios](#usage-scenarios) for porting shapes, and
[Rules for automated contributors](#rules-for-automated-contributors) for the
constraints that will fail CI if you break them.

| I want to… | Go to |
|---|---|
| Understand what this is and whether I need it | [Explanation](#explanation) |
| Get something running end to end | [Tutorial](#tutorial-route-one-completion) |
| Solve one specific problem | [How-to guides](#how-to-guides) |
| Know which entry point to use, and what holds between calls | [Reference](#reference) |
| Know how versions work before installing | [Versioning](#versioning) |
| Port an existing agent runtime onto this | [Usage scenarios](#usage-scenarios) |
| Know what is deliberately not here yet | [Roadmap](#roadmap) |
| Change this package safely | [Rules for automated contributors](#rules-for-automated-contributors) |

---

## Changelog

### Unreleased

**Added**

- **`@juno-ai/bind/session`** — versioned settled checkpoints over the existing
  loop, prepared history, explicit reply/wake input, independent eval forks,
  and required host restore/capture/commit ports. See "Checkpointed scenarios".

- **Three loop ports for a host whose transcript is durable** —
  `ToolLoopTurn.acceptMessage`, `ToolLoopParams.beforeToolMessageAccepted` and
  `ToolLoopParams.onStepSettled` (plus `StepSettlement`,
  `DiscardedTurnWithToolCallsError` and `InputBlockedBySuspendError`).
  Together they let a host record each accepted message as the loop accepts
  it, in the order it accepts it, and hand back messages it has committed.
  All three are optional and unwired behaviour is unchanged. See "How to
  commit a transcript as the loop builds it".

- **`@juno-ai/bind/skills`** — progressive disclosure for *instructions*, the
  mirror of `@juno-ai/bind/plugins`. `createSkillRegistry` for what your source
  ships, `partitionSkillCatalog` for the Tier-1 catalog and its token budget,
  `resolveActiveSkillInstructions` for the bodies (live-head or pinned to a
  completed run's hashes, over a batched `ExternalSkillSource` for skills your
  users author), `createSkillActivation` to drive the loop's `activateSkills`
  port, `admitSkillLoad` for the active-set bounds, `parseSkillMarkdown` /
  `serializeSkillMarkdown` for the `SKILL.md` interchange format over your own
  YAML, and `buildAgentSkillsDiscoveryIndex` for the Agent Skills Discovery RFC
  v0.2.0 document. See "How to give an agent loadable skills".

  Two notes for a host that already has something like this. The catalog
  returns **data, not prose** — the wording is yours, for the same prompt-cache
  reason `partitionPluginCatalog` gives. And the content digest is **the
  package's**, not a port like the one `toolCallArgsHash` takes: a skill's hash
  is computed at registration, which is synchronous, and it identifies build
  content rather than being persisted across versions. `Sha256Hex` is still a
  parameter if you would rather inject a native one.

**Breaking**

- **`sanitizeToolSchema` now emits a union for a nullable property** instead of
  collapsing it. `{type:["string","null"], minLength:1}` comes out as
  `{anyOf:[{type:"string",minLength:1},{type:"null"}]}`, with the node's
  type-bearing keywords on the typed branch and its annotations left outside;
  the old output was `{type:"string", minLength:1}` plus an "Accepts string or
  null." note on the description. Nothing in the API changed, but the emitted
  schema did — a consumer asserting on sanitizer output, or reading `.type` off
  a sanitized node, needs updating.

  The collapse was lossy in a way that broke callers. A model handed a
  type-satisfying schema and a description that says "or send null" cannot
  express "none" in the half of the declaration it treats as binding, so it
  invents a value: one tool parameter authored `type:["string","null"],
  minLength:1` received `"/"`, `". "` and `".000001"` for thousands of calls,
  each refused by the downstream API and each retried. The union shape is
  measured accepted on grok-4.3/4.5/4.6, gemini-3.5/3.6/3.7-flash and
  gpt-5.6-terra/sol/luna. A genuine multi-type union
  (`["string","number","boolean"]`) still collapses — no single shape is
  accepted by every provider — and so does a nullable type at the parameters root, in
  a branch of a root `anyOf`/`oneOf`, or in a composition branch whose
  `required` resolves against the enclosing node.
- `runToolLoop` now returns `ToolLoopResult` (`{ stopReason, stats }`) instead
  of `void`. A caller that ignores the return value is unchanged, but a
  wrapper *annotated* `Promise<void>` no longer typechecks — widen it to
  `Promise<ToolLoopResult>`.
- `RunStats` gained `cachedInputTokens`. `emptyRunStats()` and the folds set it;
  code that hand-builds a `RunStats` literal must add the field. `ToolLoopTurn`
  and `CompactionApplied` gained a matching optional `cachedInputTokens`, so
  the loop can actually populate it — return it from `callModel` if your
  transport reports one.
- `MissingActivationPortError` — a tool outcome that asks the loop to activate
  a plugin or skill while the matching port is unwired is now reported through
  `onToolCallRejected` instead of being dropped in silence. The run still
  completes; the wiring bug is no longer invisible.
- `defineTool` no longer applies `normalizeArgs` inside `execute`.
  Normalization is the dispatcher's step (it runs before the idempotency
  hash), and applying it in both places applied it twice.
- **`StopReason` no longer has `suspended`.** It split into
  `waiting_for_reply` (a tool asked a human a question; nothing happens until
  someone answers) and `resuming_later` (a tool scheduled its own resume).
  Removing a union member breaks any exhaustive `switch`, so map both new
  values wherever you handled `suspended` — and note that the old single value
  could not tell them apart at all, which is why it was split.
- **`@juno-ai/bind/plugins` now loads `zod` at runtime.** The barrel re-exports
  `defineTool` / `pluginFromTools`, and `toolWireDefinition` needs
  `z.toJSONSchema`. Every other module in the subpath was previously
  runtime-zod-free, so a consumer importing only `createToolRegistry` while
  ignoring the peer-dependency warning now fails to resolve. `zod` is a
  required (non-optional) peer, so a correctly-installed consumer is
  unaffected. `@juno-ai/bind/loop` is deliberately still zod-free — which is
  why `toolResultMessage` lives in its own type-only module.

**Added**

- `ToolLoopResult.stopReason` — `done` / `waiting_for_reply` / `resuming_later`
  / `iteration_limit` / `aborted`, one per exit, replacing the two or three
  loop-state flags every host was combining differently. The two pause reasons
  are separate values on purpose: one needs a person to act and the other does
  not, and that is the distinction a host most needs to surface. `deadline` stays in the `StopReason` union
  for a host classifying a thrown `RunTimeoutError`; the loop cannot return it,
  and the type's doc comment says why.
- `ToolLoopResult.stats` — a `RunStats` the loop folds itself: turns,
  dispatched tool calls, tokens, cost, and the model-time/tool-time split with
  a per-tool breakdown. A compaction contributes its spend but not a turn
  (`accumulateAuxiliarySpend`, also new), so `stats.turns` stays comparable
  with `maxIterations`. `ToolLoopParams.now` injects the clock.
- `@juno-ai/bind/testing` — the scripted-model fixtures this package's own
  cross-module suites use: `loopHarness`, `scriptedModel`, `toolCallTurn`,
  `finalAnswer`, `toolCall`, `freshState`, `recordingSink`, `steppingClock`.
  Credential-free multi-turn, multi-tool tests without mocking a chat client.
- `defineTool` / `pluginFromTools` (`@juno-ai/bind/plugins`) — author a tool as
  `{ name, description, schema, execute }` and get argument parsing, a
  `validation` failure the model can act on, and typed `args` in `execute`.
  Plus `toolWireDefinition` (zod → sanitized JSON Schema) and
  `toolResultMessage` (the `role:"tool"` encoding the loop itself uses).
- `ToolLoopParams.activePlugins`, `activatePlugins` and `activateSkills` are
  now optional. A host with a fixed tool surface had been required to supply
  empty functions; `activePlugins` was never read by the loop at all.

- `runToolCallsPooledByTool` accepts an optional `signal`, and
  `AbortedToolCallError` / `ToolBatchOptions` are exported from
  `@juno-ai/bind/run`. Additive — a caller that passes nothing is unchanged, and
  a test pins that. A batch that *is* given one stops claiming queued calls once
  it aborts, and those come back `rejected` with an `AbortedToolCallError`
  (`kind: "not_run"` once the loop synthesizes them), so a caller relying on
  every call always executing simply does not pass a signal.

- `createTurnTextStream` (`@juno-ai/bind/completion`) — streams a turn's
  assistant text to a live surface and repairs it across routing retries. A
  retractable surface keeps the whole fallback chain; a permanent one clamps as
  before. See "How to stream tokens to a user without breaking fallback".
- `ToolLoopParams.signal` — bounds the tool batch. Without it the deadline and
  cancellation ports are only consulted between iterations, so a budget that
  expired during the model call still let the batch run its side effects.

## Explanation

*Understanding-oriented. Read this to know why the package is shaped the way it
is; you do not need it to use the package.*

### What a harness is, and what it is not

A harness owns the parts of an agent loop that are the same for everyone: the
iteration itself — call the model, run what it asked for, repeat — plus
deciding which provider to call and what to do when it fails, bounding a run in
wall-clock time, keeping a transcript in a shape providers accept, rewriting
tool schemas that strict validators reject, and tracking which tools are
currently loaded.

A *runtime* owns the parts that are yours: identity, authorization, persistence,
transports, prompt voice, and product behaviour. `bind` deliberately contains
none of that. It never reads the environment, never touches a filesystem, and
holds no secrets — which is what lets the same code run on Bun, Node, and edge
runtimes such as Cloudflare workerd.

### The fences, and why they exist

Four constraints, enforced by lint:

- **No `@/*` application imports.** Anything the harness needs from the host
  arrives through an injected function or value — a port — never a direct
  import.
- **No Node builtins, no `process`.** No filesystem, no environment reads.
  Configuration is explicit input. Clocks are injectable, with a `Date.now`
  default as the one sanctioned exception.
- **No framework imports.**
- **Peer dependencies only** (`zod`, plus `openai` as an optional *type-only*
  peer). The host supplies the instances, so schemas never split across
  duplicate copies — a failure that stays invisible until two zod instances
  disagree about the same schema at runtime.

The fences are not stylistic. They are the reason a Cloudflare Worker and a
long-lived Node server can share this code unmodified.

### Why the plugin types are generic

`ToolPlugin` is generic over the invocation context (`TCtx`) rather than
shipping a concrete one. Almost nothing in a real tool context is common: the
abort signal is, and the rest is the host's own identity model, its
authorization, and its product features. Two applications comparing notes here
will typically find they share one field. A concrete context would therefore be
either a lowest-common-denominator or the union of several products' identity
models — so the context is a type parameter you supply, and you add your own
plugin fields by ordinary interface extension.

`ToolResult` is separately generic over the content-part type for a smaller
reason: the wire shape is the provider's, but turning bytes into a multimodal
part needs runtime-specific APIs that differ between Node and the edge. The
harness carries parts through without interpreting them, which keeps those APIs
— and the dependencies they imply — out of a package that must run on workerd.

### Why the registry is a factory

`createToolRegistry` returns an instance rather than exposing a module-level
map. Module state is per-isolate on edge runtimes and its lifetime is not the
host's; it also makes tests share state implicitly. A host that wants singleton
ergonomics wraps one instance in a module — the choice belongs to the host.

### Why a restored activation set is a hint

Progressive tool disclosure persists plugin **names**; implementations resolve
at load time. Between two runs a plugin can be renamed, gated off, or (if it is
dynamically connected) fail to reconnect. `rehydrateActivation` therefore
re-validates every persisted entry against current reality and **drops** what no
longer resolves, returning the drops with a reason rather than failing the run.
Reporting a plugin as active when its tools cannot be called is worse than
losing it.

### What stays with your application

Transports' request construction and your own error classes, credentials and
environment parsing, your routing policy configuration, billing accounting
(persistence and charging), inference logging, authorization, prompt rendering,
and run orchestration — the queue a run is scheduled on, and the enqueuing and
storage behind any child runs it spawns. The harness decides whether a child is
*allowed*; putting it on a queue is yours (see
[Spawning child runs](#spawning-child-runs-sub-agents)).

The loop is here, but the **driver** around it is not: starting a run, recording
what it did, delivering its output, and deciding when to run it again. That is
where a runtime's identity, storage, and product behaviour live, and it is why
`runToolLoop` takes a dozen observers instead of doing any of it.

---

## Tutorial: route one completion

*Learning-oriented. Follow these steps in order on a scratch file; the goal is a
working mental model, not production code.*

You will plan a route across two providers, execute it against a fake
transport, and watch the failure policy fall over to the second provider.

**1. Install.** `zod` is a peer dependency — bring your own v4 instance. Add
`openai` too if you use `/run`, `/transcript`, `/contracts`, or the package
root: they reference its message types (type-only, erased at runtime).

```sh
bun add @juno-ai/bind zod openai
```

**2. Describe your providers.** A `PlannerTransport` answers two questions:
are you available, and can you serve this model? It returns a candidate, a
recorded skip, or `unserved`.

```ts
import {
  canonicalModelIdSchema,
  providerIdSchema,
  type PlannerTransport,
} from "@juno-ai/bind/routing";

const model = canonicalModelIdSchema.parse("openai/gpt-example");
const primaryId = providerIdSchema.parse("primary");
const backupId = providerIdSchema.parse("backup");

function fakeTransport(id: typeof primaryId): PlannerTransport {
  return {
    id,
    getAvailability: () => ({ available: true }),
    resolveCandidate: () => ({
      kind: "candidate",
      candidate: {
        providerId: id,
        canonicalModelId: model,
        providerInvocationModel: "gpt-example",
        credentialSource: "platform",
        creditEligible: true,
        capabilities: new Set(["chat_completions"]),
        maxCompletionTokens: null,
        pricingBasis: { kind: "provider_reported" },
        bindingFingerprint: `${id}:gpt-example`,
      },
    }),
  };
}
```

**3. Build the plan.** Policy order is the only ordering input. The result is
frozen, secret-free, and safe to log or snapshot in a test.

```ts
import { buildRoutePlan } from "@juno-ai/bind/routing";

const { plan, skips } = buildRoutePlan({
  primaryModel: model,
  fallbackModel: null,
  requirements: {
    capabilities: new Set(["chat_completions"]),
    requestedMaxCompletionTokens: null,
  },
  policyFor: () => ({ mode: "ordered", providers: [primaryId, backupId] }),
  transports: new Map([
    [primaryId, fakeTransport(primaryId)],
    [backupId, fakeTransport(backupId)],
  ]),
});

console.log(plan.stages[0].candidates.map((c) => c.providerId)); // primary, backup
console.log(skips); // [] — nothing was filtered out
```

**4. Execute it.** Your `attempt` function performs the real call and
classifies any failure into facts. It never decides route order — that is the
executor's job.

```ts
import { executeRoutePlan, createCircuitBreaker } from "@juno-ai/bind/routing";

const result = await executeRoutePlan({
  plan,
  breaker: createCircuitBreaker(),
  attempt: async (candidate, cursor) => {
    if (candidate.providerId === primaryId) {
      return {
        kind: "failure",
        error: {
          kind: "http",
          category: "server_error",
          statusCode: 503,
          retryAfterMs: null,
          target: {
            cursor,
            providerId: candidate.providerId,
            canonicalModelId: candidate.canonicalModelId,
            providerInvocationModel: candidate.providerInvocationModel,
            durationMs: 12,
          },
          cause: new Error("upstream unavailable"),
        },
      };
    }
    return { kind: "success", value: "hello from backup" };
  },
});

if (result.ok) {
  console.log(result.value);           // "hello from backup"
  console.log(result.served.providerId); // backup
  console.log(result.fallbackKind);      // "provider"
}
```

**What you just saw.** A 503 classifies as a retriable transport failure, so the
disposition table allows traversal to the next provider and records a breaker
failure against the first endpoint. You did not write that logic, and you cannot
accidentally reorder it from inside a transport.

**5. Next.** Add a wall-clock budget with
[`createRunDeadline`](#how-to-bound-a-run-in-wall-clock-time), or add
progressive tool disclosure with
[`createToolRegistry`](#how-to-add-progressive-tool-disclosure).

---

## How-to guides

*Goal-oriented. Each answers one question and assumes you know roughly what you
are doing.*

### How to run the loop

`runToolLoop` is the engine: it calls the model, runs the tools the model asks
for, and repeats until the model stops asking, a tool suspends the run, a caller
stops it, or `maxIterations` is reached. Everything that *happens* as a result is
a callback you supply.

```ts
import { runToolLoop, type ToolLoopState } from "@juno-ai/bind/loop";

const state: ToolLoopState = {
  messages: [systemMessage, userMessage],
  inputTokens: 0,
  outputTokens: 0,
  costCents: 0,
  lastPromptTokens: 0,
  lastOutputTokens: 0,
  hasFreshTokenCount: false,
  toolCalls: 0,
};

const { stopReason, stats } = await runToolLoop({
  state,
  maxIterations: 30,

  callModel: (messages, tools) => llm.complete({ messages, tools }),
  buildTools: () => registry.toolDefinitions([...activePlugins]),
  runToolCall: (call) => dispatch(call),

  // Only if your tools can change the tool surface mid-run:
  activatePlugins: (names) => names.forEach((n) => activePlugins.add(n)),
  activateSkills: (refs) => loadInstructions(refs),
});

// `state` is mutated in place — read totals off it mid-run from a heartbeat.
console.log(state.inputTokens, state.outputTokens, state.toolCalls);

// The result is the run's conclusion, which only exists once it is over.
await persistRun({ stopReason, ...stats });
```

The two halves are deliberate. `state` is mutated rather than returned so a
heartbeat can read live totals while the run is still going; `ToolLoopResult`
is what could not exist until the run ended.

`stopReason` is the whole outcome, in one word:

| Reason | The model | What a host should say |
|---|---|---|
| `done` | Stopped calling tools | It answered |
| `waiting_for_reply` | A tool asked a human a question | It needs you to answer — nothing happens until you do |
| `resuming_later` | A tool scheduled its own resume | It paused on purpose and will come back |
| `iteration_limit` | Still calling tools at the ceiling | It ran out of room, mid-task |
| `aborted` | Cut off by `shouldStop` or the batch `signal` | It was stopped |

The two pause reasons are separate values because their consequences differ
more than any other pair: one needs a person to act, the other needs nobody to.
Collapsed into one `suspended`, a host that wanted to say which had to go back
and read `state.suspended` — the loop-state-flag reconstruction this return
value exists to replace.

`deadline` is in the `StopReason` union but never returned: the wall-clock port
(`throwIfTimedOut`) is throw-based, so an expired budget leaves the loop as a
`RunTimeoutError` your catch block maps — `classifyRunFailure` recognises the
same condition. The loop returns `aborted` for a fired signal because once a
deadline and a cancellation are combined into one `AbortSignal` it genuinely
cannot tell which one fired.

`stats` is a `RunStats`: turns, dispatched tool calls, tokens (including
provider-reported cached input, when your `callModel` returns a
`cachedInputTokens`), cost, and model time vs tool time with a per-tool
breakdown. Three things are worth reading carefully:

- **`stats` is this invocation's contribution; `state` is whatever you seeded
  plus that.** They match only if you seeded zeros. A host resuming a run seeds
  `state` from the stored totals, and then `state.costCents` is the run's
  lifetime cost while `stats.costCents` is this leg's — bill from whichever you
  mean, and don't substitute one for the other.

- **`stats.toolCalls` counts calls that *ran*; `state.toolCalls` counts calls
  the model *requested*.** They differ by exactly the work an aborted batch
  prevented, which is why they are two numbers.
- **A compaction contributes tokens, cost and model time but not a turn**, so
  `stats.turns` stays comparable with `maxIterations`.

Both are lost if the loop throws: a deadline, a cancellation, or a fatal tool
error leaves no return value, so `state` — mutated in place — is the only
accounting that survives those exits.

Pass `now` to make either measurement deterministic in a test; it defaults to
`Date.now`.

### How to test an agent without a provider

`@juno-ai/bind/testing` ships the fixtures this package's own cross-module
suites use. A scripted model is a queue of prepared turns, so a multi-turn,
multi-tool test needs no credential, no network, and no mocked chat client.

```ts
import { runToolLoop } from "@juno-ai/bind/loop";
import {
  loopHarness, toolCall, toolCallTurn, finalAnswer,
} from "@juno-ai/bind/testing";

const h = loopHarness([
  toolCallTurn([toolCall("search", { q: "bind" }), toolCall("read_file")]),
  toolCallTurn([toolCall("write_file", { path: "out.md" })]),
  finalAnswer("Done."),
]);

const { stopReason, stats } = await runToolLoop(h.params);

expect(stopReason).toBe("done");
expect(stats.turns).toBe(3);
expect(h.ran).toEqual(["search", "read_file", "write_file"]);
```

Every field of `h.params` is overridable, which is how you reach the
interesting states — `{ runToolCall }` to make one tool fail, `{ signal }` to
abort mid-batch, `{ now }` to make the timing figures deterministic
(`steppingClock()` for a fixed tick, or your own closure advanced inside
`runToolCall` when you want to *choose* each interval).
`h.ran` records what the loop *dispatched* and `h.sideEffects` what actually
*completed*; the gap between them is the answer to every cancellation question.

Two failure modes are deliberate. A script that runs out throws rather than
returning an empty turn — otherwise `maxIterations` absorbs the mistake and the
test passes while describing a run that never happened. And a duplicate
tool-call id throws at construction, rather than producing a transcript the
provider rejects ten frames deep in the loop.

### How to author a tool without writing dispatch by hand

A `ToolPlugin` dispatches by tool name, which is right for a bundle with shared
setup and pure ceremony for a flat list of independent tools. `defineTool` does
the mechanical half — parse the arguments, turn a parse failure into something
the model can act on — and `pluginFromTools` bundles the result.

```ts
import { defineTool, pluginFromTools } from "@juno-ai/bind/plugins";

const search = defineTool({
  name: "search",
  description: "Search the corpus.",
  schema: z.object({ query: z.string().min(1), limit: z.number().default(10) }),
  // `args` is typed from the schema; `limit` has already defaulted.
  execute: async (args, ctx: Ctx) => ({
    success: true,
    data: await corpus.search(args.query, args.limit, ctx.tenantId),
  }),
});

const plugin = pluginFromTools<Ctx>({
  name: "corpus",
  description: "Corpus tools.",
  tools: [search],
});
```

The schema is the single source of truth: it is converted to JSON Schema for
the model *and* used to validate what comes back, so the two cannot drift.

Bad arguments come back as `{ success: false, kind: "validation", error }`
naming the offending path — **returned, not thrown**. That distinction is the
bug this replaces: a thrown parse error turns a recoverable "you passed the
wrong field" into a dead run. An unknown tool name is likewise a returned
`not_found`, because a resumed session's history can reference a tool you have
since retired.

The validation lives on the tool, not on the bundle, so a host with its own
dispatcher can call `search.execute(rawArgs, ctx)` directly and get the same
guarantee — `pluginFromTools` only resolves names.

Two encoders come with it. `toolWireDefinition(tool, wireName?)` converts a
tool to what the provider is shown, through `sanitizeToolSchema` — `wireName`
because tool naming is host policy (Monad encodes `plugin__tool` to route a
call back to its plugin). `toolResultMessage(callId, result)` produces the
`role:"tool"` message, using the same encoding the loop synthesizes for a
failed or refused call — so a model never has to learn two error formats in one
transcript.

### How to make a tool take effect mid-batch

A model can request several tools at once, and one of them may change what the
others can do — activating a plugin, loading an instruction module. Those must
run first, alone, or a dependent call in the same batch executes against the old
tool surface.

```ts
runsSerially: (call) =>
  call.type === "function" && ACTIVATION_TOOLS.has(call.function.name),
```

The loop runs those one at a time, applies each outcome immediately, then fans
the rest out concurrently (pooled per tool name). Outcomes are reassembled in the
model's original order either way — every `tool_call_id` gets its answer in the
sequence the provider expects.

Unwired, nothing is serial. That is correct for a host whose tools do not reshape
the tool surface, and wrong the moment one does.

### How to decide which tool failures kill the run

By default a thrown tool becomes a tool error the model can read and recover
from, which is what you want for an isolated failure. Two kinds are not that:

```ts
isFatalToolError: (error) =>
  error instanceof RunCancelledError ||   // must abort, not be answered
  error instanceof PersistenceError,      // we could not RECORD the outcome
```

Cancellation has to propagate even when no `ensureNotCancelled` observer is
wired. A persistence failure matters for a subtler reason: synthesizing "the tool
failed" over a write you could not record tells the model a lie about work that
may well have happened.

Everything not fatal is answered and observed:

```ts
onToolCallRejected: (toolCallId, error) => log.warn("tool rejected", { toolCallId, error }),
```

Wire it. The model sees these failures either way; without the observer, nothing
else does.

### How to pin a model to one provider

Use a hard `only` fence. Plan-time filtering and runtime traversal both respect
it — a pinned provider that fails at request time is never retried elsewhere.

```ts
policyFor: () => ({ mode: "only", provider: complianceProviderId });
```

Useful for evals (reproducible plans) and for compliance routing.

### How to bound a run in wall-clock time

Create the deadline where you classify the outcome, and dispose it in a
`finally`.

```ts
import { createRunDeadline, classifyRunFailure } from "@juno-ai/bind/run";

const deadline = createRunDeadline({ timeoutMs: 60 * 60 * 1000, label: "agent run" });
try {
  for (;;) {
    deadline.throwIfTimedOut();
    await callModel({ signal: deadline.withExternal(cancellationSignal) });
  }
} catch (error) {
  const status = classifyRunFailure(deadline, error); // "timed_out" | "failed"
} finally {
  deadline.dispose();
}
```

**Ownership rule:** whoever needs to read `deadline.timedOut` must create and
own the deadline. A loop handed one must not dispose it; a loop given none
should mint its own, so a turn is never unbounded. Handle caller-driven
cancellation *before* calling `classifyRunFailure` — a cancellation is not a
timeout, and `timedOut` stays false when only a combined external signal fires.

### How to stop a stalled stream from hanging forever

Your SDK's `timeout` bounds establishing the request, not the gap between
streamed chunks. Hand the watchdog's signal to the transport, tell it what each
chunk carried, and ask it afterwards whether it was the one that tore the stream
down.

```ts
import { createStreamWatchdog } from "@juno-ai/bind/completion";

const watchdog = createStreamWatchdog({ external: cancellationSignal });
try {
  const stream = await client.chat.completions.create(body, { signal: watchdog.signal });
  watchdog.open(); // arms the first-token budget and the absolute cap

  for await (const chunk of stream) {
    const delta = chunk.choices?.[0]?.delta;
    // Only ANSWER output arms the tight inter-chunk budget. Reasoning deltas —
    // and the pause between reasoning and the first answer token — must stay on
    // the generous budget, or a reasoning model's normal think-then-answer gap
    // fails healthy turns.
    // Providers disagree on the reasoning field's name, and it is off-spec for
    // the OpenAI types either way — read both spellings.
    const reasoning = delta?.reasoning_content ?? delta?.reasoning;
    watchdog.observedChunk(
      delta?.content || delta?.refusal || delta?.tool_calls
        ? "answer"
        : reasoning
          ? "reasoning"
          : "none",
    );
    // …accumulate…
  }
} catch (error) {
  const stall = watchdog.stall();
  if (stall !== null) throw new MyRetriableError(describe(stall)); // your wording
  throw error; // a caller abort, or a real transport failure
} finally {
  watchdog.dispose();
}
```

`stall()` returns `null` when your own `external` signal aborted, so a
cancellation is never reported as a retriable upstream stall. Call it in the
`catch`, before `dispose()`. Budgets are validated at construction: a `NaN` or
`Infinity` budget throws rather than silently tearing down every healthy stream
(`setTimeout` coerces a non-finite delay to ~1ms — it does not disable the
timer).

### How to read the arguments of a tool call the model asked for

`JSON.parse(toolCall.function.arguments)` is the obvious implementation and it
is wrong for the commonest tool there is. Providers send `""` for a
**zero-argument** call as readily as `"{}"`, so the obvious version kills a
perfectly good call as a JSON syntax error and burns a recovery turn on a turn
that was never broken.

```ts
import { parseToolCallArguments } from "@juno-ai/bind/completion";

const read = parseToolCallArguments(toolCall);
switch (read.kind) {
  case "parsed":
    return dispatch(toolCall.function.name, read.arguments);
  case "unsupported_type":
    return toolMessage(toolCall.id, `Unsupported tool call type: ${read.type}`);
  case "unparseable":
    // Put `detail` in front of the MODEL, not only in a log — its next turn is
    // the only thing that can correct the arguments.
    return toolMessage(toolCall.id, `Invalid tool arguments: ${read.detail}`);
}
```

Every outcome is a value, not a throw, because every outcome has to end with a
`tool` message carrying this call's id — a transcript where an assistant asked
for a tool and nothing answered it is rejected by the provider on the *next*
request, so "give up on this call" was never an option.

Valid JSON that is not an object — `null`, `[]`, `42` — is refused rather than
dispatched. Tool arguments are a named parameter bag by definition, and handing
a tool an array where it expects fields turns a clear failure here into a
confusing one inside the tool, after any side effect it performs before its own
validation.

### How to assemble streamed tool calls

Providers send tool calls as indexed deltas, and the obvious accumulation loop
is wrong in four ways that all fail silently.

```ts
import { createToolCallAccumulator } from "@juno-ai/bind/completion";

const toolCalls = createToolCallAccumulator();
for await (const chunk of stream) {
  toolCalls.observe(chunk.choices?.[0]?.delta?.tool_calls);
}
const assembled = toolCalls.isEmpty ? undefined : toolCalls.assembled();
```

Key by the provider's `index`, not arrival order — deltas for index 1 can
precede index 0, and pushing onto an array transposes the calls while leaving
both parseable. Read `id` on every delta, not just the first for a slot; a
late one is legal and a call without an id cannot be answered. Concatenate
`name` as well as `arguments` — providers split it, and assigning keeps only
the last fragment (`_file` from `read_file`), which surfaces as an unknown-tool
error naming a tool the model never asked for. Order by index at the end, since
`Map` iterates in insertion order.

### How to keep a retry from repeating a tool's side effect

A durable runtime retries: a queue redelivers, a workflow step re-runs, a
reclaimed job starts the turn again. If the turn sent an email, the retry sends
a second one. A *receipt* is the row that lets the second attempt find out.

```ts
import {
  toolCallArgsHash, decideToolCallReceipt, type DigestFn,
} from "@juno-ai/bind/run";

const sha256: DigestFn = async (s) => {
  const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
  return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
};

const key = { tenantId, runId, toolName, argsHash: await toolCallArgsHash(args, sha256) };
const decision = decideToolCallReceipt({
  state: await store.read(key),            // yours — see the shape below
  effect: tool.resumable ? "resumable" : "opaque",
});

switch (decision.kind) {
  case "replay":    return (await store.read(key)).result;
  case "wait":      return retryLater(decision.reason);
  case "ambiguous": return surfaceToAHuman(decision.reason);
  case "execute": {
    await store.claim(key, decision.attempt);   // BEFORE executing
    const result = await tool.execute(args);
    await store.complete(key, decision.attempt, result);
    return result;
  }
}
```

**Identity is content, not position.** The key is
`(tenant, run, tool, arguments)`. Keying by position — run + turn + index in the
batch — looks equivalent and is not: a retried turn is a *fresh completion*, so
above temperature zero the model may reorder the batch or ask for a different
tool at the same index. Position-keyed receipts then match calls that are not
the same call, replay one tool's result as another's, and skip the tool actually
requested. Content keying fails the other way, which is the safe way: a genuinely
new call finds no receipt and runs.

**The tenant is part of the key.** Without it the key cannot be partitioned or
relocated by tenant, and two tenants' runs are not guaranteed to share a database.

**Record before executing, not after.** A receipt claimed but never completed is
the evidence that a write *may* have landed. Record afterwards and that window is
invisible.

**"Claimed but not completed" is not automatically ambiguous** — this is the part
worth getting right. On any at-least-once substrate a lease expires whenever a
worker dies *or merely stalls*, which is routine. `decideToolCallReceipt` needs a
lease and an attempt counter to tell the three cases apart:

| Receipt state | Decision |
|---|---|
| absent | `execute`, attempt 1 |
| completed | `replay` |
| failed | `execute`, attempt n+1 |
| running, lease **live** | `wait` — another attempt owns it and is alive |
| running, lease **expired**, effect `resumable` | `execute`, attempt n+1 — reclaim |
| running, lease **expired**, effect `opaque` | `ambiguous` — refuse, surface it |

`resumable` means the host records each sub-operation as it completes, so a
reclaimed attempt skips what already happened. `opaque` is one indivisible
effect with no trail, where reclaiming cannot tell "never sent" from "sent, then
crashed".

Two obligations the package cannot enforce: **lease times must come from the
store's clock**, because application clocks drift enough to steal a live lease;
and **every write must gate on the attempt fence** (`status = 'running' AND
attempts = <mine>`), inside the same transaction as the side effect where the
store allows it — fencing only the completion write leaves a window where two
attempts both believe they own the call.

**Hash the raw parsed arguments**, not the output of a schema parse that coerced
types. `canonicalJson` throws rather than serializing a value it cannot
represent faithfully — a `Map`, a `Set`, a class instance, `NaN`, a cycle —
because a silent collision here reads as "same call" and skips a write that
never happened.

### Checkpointed scenarios

Use `createLoopCheckpoint({ executionId, messages, maxIterations, bindings })`
to prepare history without inference. `runFromCheckpoint({ checkpoint, restore,
commit })` executes the existing loop; `restore` returns its executable ports and
`captureBindings`, and `commit` persists each settled checkpoint before returning
`"continue"` or `"checkpoint"`. `bindings` contains JSON configuration pins and
an environment descriptor; the host verifies/restores them before returning tools.

`parseLoopCheckpoint(JSON.parse(saved))` validates storage. Save with
`serializeLoopCheckpoint`. Waiting/finished checkpoints do no inference;
`applyCheckpointInput` accepts explicit `reply`, `wake`, or new user `message`.
Resumes preserve consumed iterations. `forkLoopCheckpoint(source, { executionId,
maxIterations, bindings })` copies history and position into a new identity with
fresh spend/budget and parent lineage. Supply isolated environment bindings for
independent eval trials. Finished forks need new input; waiting forks need their
reply or wake.

The optional `runToolLoop(params, control)` overload exposes the same awaited
boundary hook for custom drivers. Unlike `onStepSettled`, boundaries follow
nudge, compaction and suspension decisions. Existing one-argument calls keep
their return type and behavior. The controlled overload adds `"checkpointed"`.
The session driver returns invocation statistics alongside cumulative checkpoint
statistics. Hosts enforce single-writer/revision ordering in storage.

These are settled checkpoints, not recovery of an in-flight tool call. Restore
external state or reconcile execution receipts before replaying an older snapshot.
Bind does not serialize tool implementations or provision workspaces.

### How to commit a transcript as the loop builds it

`state.messages` is an in-memory array. If your host persists each message as it
lands — a Durable Object writing transitions, an event-sourced run that replays
after eviction — you need three things the array alone cannot give you: the
exact message at the moment it is accepted, its position among the results the
batch actually produced, and a point where you can hand back input your users
committed while the agent was working.

```ts
await runToolLoop({
  // …
  callModel: async (messages, tools) => {
    const completion = await transport(messages, tools);
    const empty = !completion.content.trim() && completion.toolCalls.length === 0;
    if (empty) await settleReservation(turnId, "failed");
    else await commit({ kind: "assistant_accepted", turnId, message: completion.message });
    return { ...completion, acceptMessage: !empty };
  },
  beforeToolMessageAccepted: async (message, resultOrdinal) => {
    // Throws → the message is NOT appended and the run fails.
    await commit({ kind: "tool_result_accepted", message, resultOrdinal });
  },
  onStepSettled: async ({ wouldEnd }) => {
    const pending = await claimCommittedInput({ wouldEnd });
    return { messages: pending.messages, stop: pending.runWasCancelled };
  },
});
```

**`acceptMessage: false` keeps a turn out of the transcript without hiding what
it cost.** The case is a tolerated empty completion: the provider returned
nothing, your host has already settled the slot it reserved, and appending an
empty assistant message would persist a turn that said nothing and re-send it on
every later call. Tokens and cost are still folded into `stats` and `state`. The
loop **refuses** the discard for a turn that requested tools — those results are
about to be appended and would have no request to pair with — and reports the
misuse through `onToolCallRejected` as a `DiscardedTurnWithToolCallsError`.

**`resultOrdinal` is dense over accepted results.** It is not the call's index
in `tool_calls`: an `answer`-suspend withholds its result, so the calls after it
close the gap, and the answer takes the ordinal it is accepted at when it
arrives. Reconstructing that order from `state.messages` afterwards is the one
thing that goes wrong for exactly the batch that suspended.

**`beforeToolMessageAccepted` covers results this loop produced, not messages
you hand back.** A `tool` message you return from `onStepSettled` skips the
port and takes no ordinal — you committed it before handing it over, so
reporting it back would ask you to record the same thing twice.

**A settled step is where input can join.** `onStepSettled` fires **at most
once per iteration**, and `wouldEnd` says which of the two shapes that
iteration took: `true` when the model asked for no tools (the run is about to
end), `false` when the batch's results are all in the transcript. Anywhere
else, an appended message lands between an assistant message and the results
it is waiting on, which is a transcript providers reject. Returned messages
are appended in order and the run continues; `stop` ends it as `aborted` with
them already appended, and a throw propagates out of `runToolLoop`
unconverted.

Two behaviours worth knowing before you wire it:

- At `wouldEnd: true`, `onStepSettled` runs **before** `onTurnWouldEnd`. Real
  input outranks a nudge — a turn with more to answer was never stalled.
- A returned `role:"tool"` message carrying an **open suspend's `tool_call_id`
  clears the suspension**, so a host that already has the answer keeps going
  instead of pausing for a reply it has been handed. A `wake`-suspend from
  another call in the same batch still ends the run: the answer says nothing
  about it.
- **While a suspend is open, only `tool` messages are accepted.** A `user`
  message delivered ahead of the answer is refused and reported as an
  `InputBlockedBySuspendError`: appending it is how a resumed run ends up
  sending `assistant(tool_calls) → user → tool`, which providers reject. Put
  the answering result first in the array and the rest is accepted after it.
- **`shouldStop` outranks delivered input.** The messages are kept — your host
  committed them — and the run ends as `aborted` rather than making another
  model call.

### How to stream tokens to a user without breaking fallback

Three granularities reach you, and the third is the one with a retry problem.

| You want | Use |
|---|---|
| Each assistant message as it lands | `onAssistantMessage(content)` on the loop — fires per message, including text emitted alongside tool calls, rather than batching at turn end |
| A live "N tokens so far" indicator | `onOutputProgress` into `callModel` → `onProgressUpdate(outputTokens, toolCalls)` — a count, never content |
| Individual tokens in a UI | `createTurnTextStream` from `@juno-ai/bind/completion`, driven from inside your `AttemptFn` |

The problem the third one has: routing's answer to a mid-stream failure is to
try again — same endpoint, next provider, fallback model — and each of those
re-renders a turn your user is already reading. `createTurnTextStream` solves it
by asking one question about your surface. **Can it be told to discard what it
rendered?**

```ts
const stream = createTurnTextStream({
  turnId: messageId,
  sink: { retractable: true, emit: (event) => socket.send(JSON.stringify(event)) },
});

const attempt = async (candidate, cursor) => {
  stream.beginAttempt();                       // once per attempt, including the first
  try {
    const value = await callProvider(candidate, {
      onDelta: (text) => stream.observe(text), // content only — see below
    });
    return { kind: "success", value };
  } catch (error) {
    return {
      kind: "failure",
      error: classify(error, candidate, cursor),
      producedOutput: stream.producedOutput,   // never hand-rolled again
    };
  }
};

const result = await executeRoutePlan({ plan, attempt, breaker });
stream.finish(result.ok ? "succeeded" : "failed");
return result;
```

Two lifetime rules, and neither is enforceable from inside the package:

**Construct it once per turn** — outside the executor and outside your
structured-output retry loop. One per *attempt* never sees a second attempt, so
it never resets and `producedOutput` is never true; the single-attempt path is
indistinguishable from correct, and the bug shows up only under fallback, as two
partial answers glued together.

**Always call `finish()`, and tell it how the turn ended.** A reset still armed
at the finish line means an earlier attempt's text is on screen, and the two
outcomes want opposite things. `finish("succeeded")` flushes it — the retry
succeeded with `content: null` plus tool calls, so nothing triggered the lazy
reset and that narration belongs to a turn that never said it.
`finish("failed")` drops it — every attempt failed, so the partial text is the
best thing the reader is going to get, and wiping it hands them a blank space
plus an error instead.

With `retractable: true`, `producedOutput` stays `false`, so the plan keeps
every stage. On the retry's first byte the sink receives
`{ kind: "reset", epoch, seq, reason }` and re-renders from scratch. With
`retractable: false` — a message already posted through a third-party API, an
email, a webhook, an append-only row — it latches on the first byte and routing
clamps to propagate-only, which is the old behaviour and the right one.

Your client needs three lines to be correct under a transport that can reorder or
duplicate, because an in-flight delta from attempt 1 can arrive *after* attempt
2's reset and text alone cannot be told apart from stale text:

```ts
// Per turn: { lastSeq: -1, newestEpoch: -1, rendered: "", held: new Map() }
// `lastSeq` starts at -1 — `seq` starts at 0, so seeding it to 0 silently drops
// the first delta of every turn, on the single-attempt path that is almost all
// traffic.
const turn = state.getOrCreate(event.turnId);  // scope everything to the turn
if (event.seq <= turn.lastSeq) return;         // replay or duplicate, drop
if (event.seq > turn.lastSeq + 1) return buffer(turn, event);  // arrived early
turn.lastSeq = event.seq;
if (event.epoch < turn.newestEpoch) return;    // stale attempt, drop
turn.newestEpoch = event.epoch;
if (event.kind === "reset") turn.rendered = "";
else turn.rendered += event.text;
drainBuffered(turn);                           // apply anything that was early
```

Both keys are load-bearing and they do different jobs. **`seq`** is monotonic
within the turn and never restarts, so it is what makes the stream tolerant of a
transport that duplicates or reorders — drop anything at or below the last seq
applied, hold anything that arrives ahead of it. **`epoch`** identifies the
attempt, so it is what tells a *fresh* delta from a stale one after a reset.
Neither substitutes for the other: without `seq` a duplicated text event appends
twice and same-epoch chunks concatenate in arrival order; without `epoch` a
delta from the wiped attempt is indistinguishable from the retry's.

**Bound the hold buffer, and define when a turn ends.** The rule above holds an
early event until its gap fills — and on a reconnect the gap never fills, because
the events that would have closed it were dropped. Left alone, the surface then
freezes on whatever the *retracted* attempt rendered, which is the exact outcome
this module exists to prevent, and the buffer grows one entry per token. So: cap
the buffer (a count or a timeout), and on overflow resync from the persisted
message rather than continuing to hold. For the same reason the client needs a
turn-final signal — `onAssistantMessage`, or the persisted row landing — at which
it drops the turn's state entirely. Nothing on this wire tells it; that is the
host's to define.

If your transport already guarantees ordered exactly-once delivery to the client
(a single WebSocket with no replay window, say), the `seq` half collapses to a
no-op and the two `epoch` lines are enough — but say so deliberately rather than
discovering it under load.

**In React**, the recipe above mutates in place. Dropped into a store as written,
`getSnapshot` returns an identity-stable object and `useSyncExternalStore` never
re-renders — the stream looks dead. Publish a fresh snapshot per applied event.
And key the rendered element by `turnId`, never by `epoch`: keying by epoch turns
every reset into an unmount, discarding focus, selection and scroll position when
the contract only ever needed a content update.

**`turnId` is not decoration either.** `epoch` and `seq` both restart each turn,
so a client that carried "newest epoch" across turns drops every event after the
first turn that retried, and a later turn's reset tells it to wipe an earlier,
committed message.

Three things stay yours:

- **Reasoning deltas.** The watchdog gates its tight budget on the first
  *answer* token, so reasoning can flow well before one. Whether "thinking…"
  counts as output the user has seen is a product call — express it by choosing
  what you pass to `observe`.
- **Tool-call deltas are not output** and should not go through `observe`. A
  tool call is not a side effect until it is *dispatched*, which happens after
  the turn — so a turn that dies having streamed only tool-call bytes changed
  nothing anyone can see, and clamping it forfeits a fallback for free. If you
  have a genuine mid-attempt effect, call `stream.markProducedOutput()`.
- **How much flicker is acceptable.** A wipe is not only a flicker — it collapses
  the message's height mid-stream, so an auto-scrolled transcript lurches, and it
  destroys any text selection inside that message. If the body is an `aria-live`
  region, every wipe re-announces the whole answer from the top; keep it
  `aria-busy` while streaming and announce once at the end instead.
  `maxResets` is opt-in with no default: a
  plan with three stages, three candidates and two defect retries can legally
  wipe the screen more than twenty times. Spending the budget latches
  `producedOutput` so routing stops traversing — it does *not* stop emission,
  because an attempt already in flight may be the one that succeeds and its
  answer still has to reach the reader. Note the budget therefore also shapes how
  many endpoints record a failure against the circuit breaker for one turn.

- **Which surface gets which callback.** `onAssistantMessage` on the loop fires
  once per completed assistant message; these events stream one attempt of one
  message. Wiring both to the same UI element delivers the text twice. Deltas
  drive the live view; `onAssistantMessage` drives the permanent record.

Structured-output retries run in your loop, outside the executor, and get the
same treatment — open them with `stream.beginAttempt("structured_output_retry")`
so the user sees the re-ask replace the malformed JSON rather than follow it.

### How to map your transport errors onto the routing taxonomy

`failureDisposition` decides what the router does with a classified failure, but
something has to produce the classification. Give `classifyAttemptError` two
ports — one that recognizes your error class, one that overrides the neutral
HTTP mapping where your provider disagrees with it — and it does the rest.

```ts
import { classifyAttemptError, type AttemptClassification } from "@juno-ai/bind/routing";

const CLASSIFICATION: AttemptClassification = {
  asTransportFailure: (error) =>
    error instanceof MyLLMError
      ? { kind: error.kind, statusCode: error.statusCode, retryAfterMs: error.retryAfterMs }
      : null,
  // This gateway answers 403 for moderation-flagged INPUT. The neutral mapping
  // reads 403 as a credential failure and opens the circuit immediately —
  // degrading a healthy shared endpoint for every tenant over one prompt.
  categorizeStatus: (status, providerId) =>
    status === 403 && providerId === MY_GATEWAY ? "provider_bad_request" : null,
};

// `target` is the `AttemptTarget` you assemble in your `attempt` callback from
// the candidate and cursor it was handed — see the tutorial's step 4.
const attemptError = classifyAttemptError(error, target, CLASSIFICATION);
```

An `AbortError` is classified first, whatever else is true of it. Anything
`asTransportFailure` does not recognize becomes a propagating `client_error`: an
error that escaped your transport without becoming one of its own is a bug, and
retrying it against every provider and your fallback model arrives at the same
exception having spent a whole plan. `retryAfterMsFromHeaders` lives alongside
it and feeds the `retryAfterMs` the breaker uses to extend a cooldown — clamp
it before using it as a delay anywhere else, since it is a value the upstream
chose and RFC 9110 puts no ceiling on it.

### How to stop a run's progress writes from stampeding

```ts
import { createCoalescedHeartbeat } from "@juno-ai/bind/run";

const heartbeat = createCoalescedHeartbeat({
  coalesceMs: 10_000,
  flush: () => db.bumpRunRow(runId),
  onError: (error) => log.warn("heartbeat flush failed", { error }),
});

await heartbeat.beat();                 // coalesced
await heartbeat.beat({ force: true });  // always flushes, resolves after the write
```

Flush errors go to `onError` and are swallowed, so a transient database hiccup
never aborts a run. Flushes drain one at a time, so two overlapping writes can
never land out of order.

### How to keep a provider from rejecting your whole tool list

Strict validators reject the **entire** request — every tool — on the first
schema violation. Run each tool's JSON Schema through the sanitizer before it
reaches the model.

```ts
import { sanitizeToolSchema } from "@juno-ai/bind/tools";

const wireTools = tools.map((tool) => ({
  type: "function",
  function: {
    name: tool.name,
    description: tool.description,
    parameters: sanitizeToolSchema(tool.inputSchema),
  },
}));
```

Every transform is correctness-preserving and was bisected against live
inference. It fixes, among others: a `type` array where a provider requires a
scalar; `enum` on a non-string type; `required` entries with no matching
property; a boolean `additionalProperties: false` on a nested object; and a
parameter literally named `properties`. Run it on third-party (e.g. MCP) tool
schemas too — those are where the violations usually come from.

One transform is worth knowing about even if no provider forced it. A **nullable
property** — `{type:["string","null"], minLength:1}` — is rewritten to
`{anyOf:[{type:"string",minLength:1},{type:"null"}]}` rather than reduced to its
non-null type with a note on the description. Reducing it is what a strict
provider needs, but it leaves the model unable to say "none" in the part of the
declaration it treats as binding, so it invents a value that satisfies the type:
`"/"`, `" "`, `"null"`, `"undefined"`. If you author nullable parameters, you do
not need to hand-write the union — the sanitizer produces it.

### How to repair a transcript before sending it

```ts
import { validateAndHealMessages } from "@juno-ai/bind/transcript";

const { messages, issues } = validateAndHealMessages(transcript);
if (issues.length > 0) log.warn("healed transcript", { issues });
```

Detects and repairs orphan tool results, dangling unanswered tool calls, empty
assistant messages mid-conversation, and a trailing assistant turn — which is
prefill on one provider's native API and a hard rejection through another.

### How to add progressive tool disclosure

Model tool-selection accuracy degrades past a few dozen tools, and every tool's
schema is resent on every turn. Load a small core set, announce the rest as a
catalog, and activate on demand.

```ts
import {
  createToolRegistry,
  initialActivePlugins,
  partitionPluginCatalog,
} from "@juno-ai/bind/plugins";

const registry = createToolRegistry<MyPlugin>({
  corePlugins: ["messaging", "memory"],
  aliases: { "old-name": "new-name" },
  onRegister: (plugin) => indexPluginSkills(plugin),
});

registry.register(myPlugin);

const active = new Set(initialActivePlugins(registry.corePlugins()));
const { active: shown, loadable } = partitionPluginCatalog(active, registry.summaries());
```

`aliases` are permanent: persisted activation state stores names, so an alias is
how a rename avoids silently stripping capabilities from live sessions without a
data migration.

`partitionPluginCatalog` returns **data, not prose** — catalog wording is your
system prompt's business, and re-rendering it byte-identically for unchanged
inputs is what preserves a provider's prompt-cache prefix.

### How to give an agent loadable skills

A *skill* is a markdown procedure or reference module the model can pull into
its own instructions: a catalog line it always sees, a body injected into the
system prompt once loaded, and resources it may read after that. It is the same
progressive disclosure as tool activation, applied to knowledge — and it exists
because the accumulated know-how of a real workspace does not fit in a context
window, while a catalog line costs about fifty tokens.

Register what your own source ships:

```ts
import { createSkillRegistry, partitionSkillCatalog } from "@juno-ai/bind/skills";

const skills = createSkillRegistry({ onWarn: (message, fields) => log.warn(message, fields) });

skills.registerPlatform({
  name: "triaging-inbound-work",
  description: "How this team triages inbound requests.",
  whenToUse: "When asked to sort, rank, or route a queue of incoming work.",
  render: () => TRIAGE_BODY,
});
// A skill contributed by a plugin is offered only to an agent that can load
// that plugin — a recipe for tools it cannot call is noise.
skills.registerPlugin("documents", DRAFTING_SKILL);
```

Render the catalog from a partition, in your own words:

```ts
const active = new Set<string>(session.activeSkills);
const available = skills.summaries({ availablePlugins: agent.plugins });
const { active: loaded, loadable, truncated } = partitionSkillCatalog(active, available);
```

`partitionSkillCatalog` returns **data, not prose**, exactly as
`partitionPluginCatalog` does. It also enforces a token budget by marking the
overflow `name_only` rather than dropping it — a name is still enough for the
model to call `load_skill` and read the real description, whereas a skill it
cannot see is one it can never ask for. Pass your own `cost` if your line format
differs from `- name: description — whenToUse`; a budget is only as honest as
its measurement.

Then wire activation into the loop, and bound how much can be loaded:

```ts
import {
  admitSkillLoad,
  createSkillActivation,
  estimateSkillBodyTokens,
  resolveActiveSkillInstructions,
} from "@juno-ai/bind/skills";

const loadedSkillShas: Record<string, string> = {};
const resolveActiveInstructions = (activeRefs: string[]) =>
  resolveActiveSkillInstructions({ activeRefs, available, registry: skills });

const activation = createSkillActivation({
  availableSkills: available,
  activeSkills: active,          // yours: seeded before the first turn, persisted after the last
  loadedSkillShas,               // yours: hoist it so a failed run still records what it read
  store: { resolveActiveInstructions },
  applyInstructions: (instructions) => renderSystemPrompt({ instructions }),
});

await runToolLoop({ ...params, activateSkills: (refs) => activation.activateSkills(refs) });
```

Two details are worth knowing before you wire your own `load_skill` tool. The
resolver renders in a **total order** (origin, then name) rather than the order
skills were loaded, because these bodies sit high in the system prompt and a
resumed session's persisted order would otherwise byte-shift the cacheable
prefix. And `admitSkillLoad` is what keeps a looping model from loading its way
into a context-limit error — you measure, it judges:

```ts
// Cheapest first. The count is a `Set.size`; the token bound needs the
// resolver, which for a host-stored skill is a query plus the wrapping of every
// active body. A model that has hit the cap keeps calling `load_skill`, so
// folding these into one pass pays that cost on every call purely to refuse.
const byCount = admitSkillLoad({ activeCount: active.size });
if (!byCount.admitted) return { success: false, kind: "validation", error: byCount.reason };

const { instructions } = await resolveActiveInstructions([...active, ref]);
const byTokens = admitSkillLoad({ projectedBodyTokens: estimateSkillBodyTokens(instructions) });
if (!byTokens.admitted) return { success: false, kind: "validation", error: byTokens.reason };
```

Omitting a measurement omits its bound, which is what makes the two-pass shape
expressible — and a measurement that arrives broken (`NaN`, negative) refuses
rather than admits, because a nonsense count is not evidence of room.

Skills your *users* author live in your database, not the registry. Hand the
resolver an `externalSource` and it routes any ref that is not `platform:<name>`
to you — batched, so one activation stays one query:

```ts
resolveActiveSkillInstructions({
  activeRefs,
  available,
  registry: skills,
  externalSource: async (refs, pinnedShas) => loadWorkspaceSkills(workspaceId, refs, pinnedShas),
});
```

`pinnedShas` is how a replay stays honest. Each resolution records the
`contentSha` it actually rendered; feed a completed run's map back in and every
skill resolves to the body that run saw, so an eval is not silently grading
against instructions that were edited afterwards.

To read or write the interchange format, pass your own YAML implementation —
the package takes peer dependencies only:

```ts
import { parseSkillMarkdown } from "@juno-ai/bind/skills";
import yaml from "js-yaml";

const parsed = parseSkillMarkdown(raw, { parse: yaml.load, stringify: (v) => yaml.dump(v, { lineWidth: -1 }) });
```

Import is deliberately lenient — it repairs the unquoted-colon frontmatter
mistake and warns — and fails only on frontmatter that is not YAML and on a
missing `description`, the one field with no sensible default.

### How to restore a persisted activation set

```ts
import { rehydrateActivation } from "@juno-ai/bind/plugins";

const { active, dropped } = await rehydrateActivation(persistedNames, {
  canonicalizeName: (name) => registry.canonicalizeName(name),
  resolve: async (name) => {
    if (isGatedOffThisRun(name)) return "unavailable";
    if (!isDynamic(name)) return registry.get(name) ? true : "unknown";
    return (await connect(name)) ? true : "unreachable";
  },
});

for (const drop of dropped) log.warn("activation dropped", drop);
```

The walk canonicalizes, de-duplicates (two legacy names collapsing to one plugin
resolve **once**, so a reconnect is not paid twice), and drops rather than
throws. `resolve` is async precisely so a reconnect can happen inside it.

### How to scope the circuit breaker per tenant

Pass a non-secret `credentialScope`. Without it, one tenant's revoked
bring-your-own key opens the circuit for every tenant sharing the same
`credentialSource`.

```ts
breaker.recordFailure({
  providerId,
  invocationModel,
  credentialSource: "tenant",
  credentialScope: tenantTag, // opaque, non-secret — it lands in state keys
});
```

If a half-open probe ends without a recordable outcome (an abort, a propagated
client error), call `releaseProbe` so the slot cannot stick.

---

## Reference

*Information-oriented. The exported types are the specification — read them in
your editor. This section covers what the types cannot say: which entry point
to reach for, and the contracts that hold between calls.*

Every export is re-exported from the package root **except `@juno-ai/bind/testing`,
which is subpath-only so fixtures never reach a production bundle** — but prefer
the subpath either way, since it
keeps a consumer who only wants routing from pulling in the rest.

| Import | Owns | Reach for it when |
|---|---|---|
| `@juno-ai/bind/routing` | Route plans, the planner, the failure taxonomy and the classifier that maps your errors onto it, the plan executor, the circuit breaker, billing-basis arithmetic, config degradation | You call more than one provider or model, or you want retries and fallback governed by one table |
| `@juno-ai/bind/completion` | Streaming idle watchdog (time-to-first-token, inter-chunk, absolute cap), streamed tool-call assembly, completion-defect detection, the tool-call argument read (`parseToolCallArguments`), and the structured-output retry predicates | You read a streamed completion |
| `@juno-ai/bind/contracts` | Turn vocabulary — `TurnFn`, `ModelTurnResult`, `StopReason`, `RunStats` and its folds | You want one seam between your loop and any LLM client, and comparable per-run metrics |
| `@juno-ai/bind/loop` | `runToolLoop` — the iteration engine: model turn, two-phase tool batch, activation, compaction, interrupts, suspend | You want the agent loop itself, not just the pieces to build one |
| `@juno-ai/bind/session` | Validated checkpoints, prepared history, explicit continuation input, forks and a driver over the existing loop | You need resumable scenarios with host-owned persistence and environment restoration |
| `@juno-ai/bind/run` | Wall-clock deadline, failure classification, coalesced heartbeat, tool-batch pooling, child-run lineage and admission, poll backoff, tool-call receipts for retry-safe side effects | A run must be bounded, observable, and able to say *why* it stopped — or it can spawn runs of its own |
| `@juno-ai/bind/transcript` | `validateAndHealMessages` | You send transcripts to more than one provider, or you build them across turns |
| `@juno-ai/bind/tools` | `sanitizeToolSchema` | Any tool schema reaches a provider — especially third-party ones |
| `@juno-ai/bind/plugins` | Tool/plugin vocabulary, `defineTool` / `pluginFromTools`, the wire-definition and tool-result encoders, the registry factory, progressive-disclosure activation | You are authoring tools, or you have more of them than fit comfortably in one prompt |
| `@juno-ai/bind/skills` | Skill vocabulary — the code-skill registry, the `SKILL.md` codec, the content hash, the catalog's total order and token budget, the active-instruction resolver, the activation controller, active-set bounds, and the Agent Skills Discovery document | Your agent needs loadable instructions, not just tools — a workspace's procedures, a house style, a runbook |
| `@juno-ai/bind/testing` | Scripted-model fixtures — `loopHarness`, `scriptedModel`, `toolCallTurn`, `finalAnswer`, `freshState`, `recordingSink`, `steppingClock` | You want multi-turn, multi-tool tests without a credential or a mocked chat client |

### Contracts the types do not carry

**Routing — attempt order is fixed.** Structured-output attempt → model stage →
provider candidate → same-endpoint retry. Nothing else reorders it: not
transport registration order, not map iteration, not the clock.

**Routing — transports classify, they never decide.** An `AttemptFn` reports a
fact (`aborted` / `completion_defect` / `network` / `http` + category);
`failureDisposition` alone decides retry, next-provider, fallback-model, or
propagate. Putting routing logic in a transport is the one way to break the
guarantee that identical inputs produce identical plans.

**Routing — an attempt that already produced output is never replayed.** Every
other input to a disposition is a property of the *error*; this one is a
property of the *attempt*, and only your transport knows it. A transport that
buffers the whole completion can always replay; one that forwards deltas to a
live view has already shown someone tokens, and retrying — on the same endpoint
or another provider — appends a second partial answer to what they are reading.
Set `producedOutput: true` on the failure outcome and the executor withholds all
traversal. **If you stream to a UI and do not set it, you have this bug.** The
breaker still records the failure: the endpoint really did fail, and hiding that
because it failed late is backwards.

**Routing — a healthy endpoint must not be punished for a bad request.**
Request-shaped rejections (a 400, a moderation refusal) traverse to another
provider but record **no** breaker failure. Otherwise one caller's malformed or
flagged prompt degrades a shared endpoint for everyone.

**Routing — a fallback model equal to the primary is ignored**, so you can pass
a configured fallback through unconditionally without producing a duplicate
stage.

**Routing — breaker keys must stay secret-free.** `credentialScope` lands in
state keys; pass an opaque tag, never key material. Omitting it means one
tenant's revoked key opens the circuit for every tenant sharing that
`credentialSource`. Defaults: open after 3 consecutive failures, 60 s cooldown,
60 s ceiling including `Retry-After` extensions. Resolve a half-open probe that
ended without a recordable outcome via `releaseProbe`, or the slot sticks.

**Run — a child is admitted before it exists, never after.** `admitChildRun`
judges counts you supply; call it ahead of enqueuing. A chain bounded only once
its runs are on the queue is not bounded, it is billed. A rule whose measurement
is `NaN` or `Infinity` refuses rather than admits — every comparison is false
against `NaN`, so the naive reading of a broken count is an unbounded chain.

**Run — "could not measure" is an omission, never a sentinel.** The refusal
above is for a count you *did* supply and that came back broken. A bound you
could not measure at all — the query threw, the counter was unreachable — is
expressed by leaving that rule out of the array, which admits. The two are
opposite answers to opposite questions, and passing `NaN` for "unknown" turns a
transient database blip into every agent in your system refusing to run. Which
of the two a given failure is is yours to decide; the harness only judges what
it was handed.

**Completion — the zero-argument rule has two halves and they must agree.**
`detectCompletionDefect` decides a tool call with *empty* arguments is
legitimate and passes it through; `parseToolCallArguments` is what then reads
it as the zero-argument call it is. Both read one `toolCallArgumentsAbsent`, so
adopting only the detection half means independently reinventing the matching
parse — and getting it wrong the way everyone does, with a bare `JSON.parse`.

**Run — chain lineage is "null means me".** A root run's `rootRunId` and
`parentRunId` are both `null`, because a chain's origin has no id to point at
until its own row exists. Read any chain's root as `chain.rootRunId ?? runId`;
`descendChain` does that when it hands the id down, so every descendant carries
a concrete root. Adopting the parent's id at *every* level instead makes each
generation a fresh chain, and every per-chain bound then counts the wrong set
and silently never fires.

**Run — whoever classifies the outcome owns the deadline.** `timedOut` reflects
*that* deadline firing, and stays false when only a combined external signal
aborts — which is what separates a timeout from a cancellation. Handle
cancellation before calling `classifyRunFailure`. A loop handed a deadline must
not dispose it; a loop given none should mint its own, so a turn is never
unbounded.

**Run — pooling preserves input order and answers everything.**
`runToolCallsPooledByTool` caps concurrent calls to the *same* tool at 5 while
different tools fan out fully, and returns `PromiseSettledResult`s in input
order. A rejection still needs a synthesized error tool message, or the next
request carries an unanswered `tool_call_id`.

**Routing — a policy that names a provider you have not configured degrades,
it does not fail.** `buildRoutePlanWithConfigDegradation` drops such a stage at
*plan time* and records why, so a missing key costs you that provider rather
than making the model unavailable. Runtime failures keep the fence: a configured
`only` provider that errors at request time is still never retried elsewhere.

**Run — heartbeat flushes drain one at a time**, so two overlapping writes
cannot land out of order. Flush errors go to `onError` and are swallowed; a
transient storage failure never aborts a run.

**Contracts — model time and tool time stay separate.** Task wall-clock
conflates provider inference speed with tool execution. `RunStats` reports both,
plus a per-tool breakdown; field shapes follow the [StirrupJS](https://github.com/stirrupjs/stirrup)
`speedStats` methodology, so numbers stay comparable with published benchmarks
and a slow tool never reads as a slow model.

**Transcript — the second argument is an assertion, not a silence.** Ids passed
as `allowedOpenToolCallIds` are *legitimately* open (a suspended call awaiting a
human answer). Because this is the universal heal site and a correct transcript
never reaches it with a suspended call unpaired, an allowed id arriving unpaired
means resume failed — and it fails loudly rather than being quietly kept.

**Tools — sanitize third-party schemas too.** Strict validators reject the
*entire* request, every tool, on the first violation, and third-party schemas
are where violations usually originate. Every transform is
correctness-preserving and was bisected against live inference.

**Plugins — `rawJsonSchema` overrides, it does not replace.** `parameters` is
always required; a host authoring tools as raw JSON Schema supplies a
placeholder there and puts the real schema on `rawJsonSchema`, which is what
reaches the model.

**Plugins — availability is evaluated per call.** `get()` returns `undefined`
for an unavailable plugin while `all()` still includes it, so a plugin can stay
registered for configuration purposes while being gated off for a run. A
connection going unhealthy mid-process takes its tools out of the catalog with
no re-registration.

**Plugins — aliases are permanent.** Activation state persists *names*, so an
alias is how a rename avoids stripping capabilities from live sessions without a
data migration. Removing one silently downgrades every session that still stores
the old name.

**Plugins — `hidden: true` keeps a tool callable but unadvertised**, for resumed
sessions whose history references a tool you have retired from the prompt.

**Plugins — rehydration drops, it never throws.** `resolve` returns `true` to
keep or a drop reason to discard, and may be async so a reconnect can happen
inside it. The walk canonicalizes and de-duplicates first, so two legacy names
collapsing to one plugin resolve once — a reconnect is not paid twice.

**Plugins — catalog partitioning returns data, not prose.** Wording belongs to
your system prompt, where re-rendering byte-identically for unchanged inputs is
what preserves a provider's prompt-cache prefix.

### Suspend semantics

A first-party tool returns a `SuspendDirective` on a **successful** result to end
the run with its call recorded as awaiting resolution. The resume kind decides
what happens to the tool message:

- `"answer"` — the reply becomes this call's `role:"tool"` result, so the loop
  **withholds** the message. At most one may be open per run.
- `"wake"` — a time or event resume that re-enters via a prompt and **keeps** the
  message.

`request` is an opaque render/route payload, validated by the consumer and never
inspected by the loop.

An open `"answer"` suspend can also be resolved **within the same step**: a
`role:"tool"` message returned from `onStepSettled` that carries the open call's
`tool_call_id` clears the suspension and the run continues, for a host that
already holds the reply. A `"wake"` suspend from another call in the same batch
still ends the run — see "How to commit a transcript as the loop builds it".

---

## Usage scenarios

*Sketches, not runnable files — they name types loosely and omit imports. Each
shows the shape of one migration an existing agent runtime typically has to
make. Read the [Reference](#reference) for exact signatures.*

### Tools authored as raw JSON Schema, not zod

`ToolDef.parameters` is a zod schema, but a host whose tools are already JSON
Schema does not need to rewrite them — set `rawJsonSchema` and it is used
verbatim. Keep `parameters` as a permissive placeholder if you validate
elsewhere.

```ts
const toolDef: ToolDef = {
  name: "search",
  description: "Search one page of results.",
  parameters: z.unknown(),           // unused when rawJsonSchema is present
  rawJsonSchema: existingJsonSchema,  // your hand-written schema, as-is
  annotations: { readOnlyHint: true },
};

// Sanitize on the way to the model, regardless of which form it came from.
const wire = {
  type: "function",
  function: {
    name: toolDef.name,
    description: toolDef.description,
    parameters: sanitizeToolSchema(toolDef.rawJsonSchema ?? z.toJSONSchema(toolDef.parameters)),
  },
};
```

### One plugin per integration

A natural plugin boundary is the external system a group of tools talks to.

`isAvailable` gates *whether the deployment has this integration at all* — a
build flag, a missing binding, an edition that does not ship it. It must return
the same answer for the whole life of the process: the catalog is re-rendered
several times per run, and a value that flips mid-run changes the system prompt
between turns, which costs the prompt-cache prefix and can strand a tool call
the model already issued.

Health that changes at runtime belongs at *invocation* instead. Let the call
fail, and return the reason:

```ts
function integrationPlugin(adapter: Adapter, conn: Connection): ToolPlugin<Ctx> {
  return {
    name: adapter.id,                       // "calendar", "issues", "docs", …
    description: adapter.catalogLine,       // one line; it lands in the prompt catalog
    isAvailable: () => adapter.installed,   // constant for this process
    tools: adapter.tools.filter((t) => conn.grants.has(t.capability)),
    execute: async (toolName, args, ctx) => {
      if (conn.status !== "healthy") {
        // The model can retry, or route around it — a dropped catalog entry
        // tells it nothing.
        return { content: `${adapter.id} is unreachable right now.`, isError: true };
      }
      return adapter.invoke(toolName, args, ctx);
    },
  };
}

for (const conn of connections) {
  registry.register(integrationPlugin(adapterFor(conn), conn));
}
```

For a plugin whose *connection* is established per run rather than per process,
restoring a persisted activation is where the reconnect belongs — see
[How to restore a persisted activation set](#how-to-restore-a-persisted-activation-set).

Splitting one large integration into several plugins (`mail`, `calendar`,
`files`) is usually worth it: the unit of activation should match the unit of
intent, and a 20-tool plugin is a large thing to load for one call.

### A per-request registry on an edge runtime

Module-level state is per-isolate and outlives a single request unpredictably.
Build the registry inside the request instead — the factory is cheap.

```ts
export default {
  async fetch(request: Request, env: Env) {
    const registry = createToolRegistry<MyPlugin>({
      corePlugins: ["core", "discovery"],
    });
    for (const plugin of await loadPluginsFor(env, request)) {
      registry.register(plugin);
    }
    return handle(request, registry);
  },
};
```

### A single-provider transport

You do not need multiple providers to benefit from the plan/execute split — one
transport still gets you the failure taxonomy, the retry budget, and the
breaker.

```ts
const transport: PlannerTransport = {
  id: providerIdSchema.parse("openrouter"),
  getAvailability: () =>
    env.API_KEY ? { available: true } : { available: false, reason: "no key" },
  resolveCandidate: (model, requirements) => {
    if (!supports(model, requirements.capabilities)) {
      return { kind: "skip", skip: { canonicalModelId: model, providerId: id, reason: "capability_mismatch" } };
    }
    return { kind: "candidate", candidate: candidateFor(model) };
  },
};
```

### Replacing a hand-rolled retry/fallback loop

A typical hand-rolled loop is `for (model) for (attempt)` with an ad-hoc
`retry | fallback | terminal` decision. Move the decision to the taxonomy: your
transport reports **facts**, the executor decides order.

```ts
// Before: the transport decided what to do next.
//   if (status === 429 || status >= 500) retry();
//   else if (status === 401) throw;
//   else tryNextModel();

// After: the transport only classifies — and `classifyAttemptError` does that
// for you, so the only thing you write is how to recognize your own error class.
const CLASSIFICATION: AttemptClassification = {
  asTransportFailure: (error) =>
    error instanceof MyLLMError
      ? { kind: error.kind, statusCode: error.statusCode, retryAfterMs: error.retryAfterMs }
      : null,
};

const attempt: AttemptFn<Completion> = async (candidate, cursor) => {
  const startedAt = performance.now();
  try {
    return { kind: "success", value: await callProvider(candidate) };
  } catch (cause) {
    const target = {
      cursor,
      providerId: candidate.providerId,
      canonicalModelId: candidate.canonicalModelId,
      providerInvocationModel: candidate.providerInvocationModel,
      durationMs: Math.round(performance.now() - startedAt),
    };
    return { kind: "failure", error: classifyAttemptError(cause, target, CLASSIFICATION) };
  }
};
```

Do not hand-roll that `catch` from the neutral pieces — mapping statuses
yourself is where the consequential mistakes live. The neutral mapping reads
**403 as a credential failure**, which opens the breaker immediately; if your
gateway also answers 403 for a moderation-flagged *prompt*, that one tenant's
request takes a healthy endpoint out of rotation for everyone sharing the
breaker key. Supply a `categorizeStatus` port instead — see
[How to map your transport errors onto the routing taxonomy](#how-to-map-your-transport-errors-onto-the-routing-taxonomy).

Two behaviours you get for free and probably did not have: a request-shaped
rejection (a 400, a correctly-classified moderation refusal) traverses to
another provider **without** opening the breaker, and an empty or truncated
completion is retried on the same endpoint before any fallback.

### Adapting a non-SDK client to `TurnFn`

`TurnFn` is the seam between a loop and any client. If you hand-roll SSE, adapt
at this boundary and the rest of the harness does not care.

```ts
const turn: TurnFn = async (messages, tools, signal) => {
  const startedAt = performance.now();
  let ttftMs: number | null = null;

  const { message, usage } = await streamCompletion({
    messages, tools, signal,
    onFirstToken: () => { ttftMs ??= Math.round(performance.now() - startedAt); },
  });

  return {
    message,
    usage: {
      inputTokens: usage.prompt_tokens ?? 0,
      outputTokens: usage.completion_tokens ?? 0,
      cachedInputTokens: usage.cached_tokens ?? null,
      costCents: usage.cost != null ? usage.cost * 100 : null,
    },
    timings: { ttftMs, generationMs: Math.round(performance.now() - startedAt) },
  };
};
```

### Accumulating run statistics

**If you use `runToolLoop`, you do not need this** — it folds `RunStats` itself
and returns it. This is the shape for a host driving turns by hand.

Fold each turn and each tool call as they complete; `RunStats` keeps model time
and tool time separate so a slow tool never looks like a slow model. Model
calls that are not agent turns — a compaction pass — go through
`accumulateAuxiliarySpend` instead, which charges the tokens and the time
without counting a turn.

```ts
let stats = emptyRunStats();

for (const turn of turns) {
  const result = await turnFn(messages, tools, signal);
  stats = accumulateTurn(stats, result);

  for (const call of result.message.tool_calls ?? []) {
    const startedAt = performance.now();
    await dispatch(call);
    stats = accumulateToolCall(stats, call.function.name, performance.now() - startedAt);
  }
}

await persistRun({ ...stats, stopReason: "done" satisfies StopReason });
```

### Batching a turn's tool calls

Reads can overlap; writes usually should not. Pool the reads and keep the
transcript in the model's original call order.

```ts
const reads  = calls.filter((c) => isReadOnly(c));
const writes = calls.filter((c) => !isReadOnly(c));

const readResults = await runToolCallsPooledByTool(reads, (call) => dispatch(call));

const writeResults: Outcome[] = [];
for (const call of writes) writeResults.push(await dispatch(call));

// Re-emit in the order the model asked for, so every tool_call_id is answered.
for (const call of calls) {
  messages.push(toolMessageFor(call, resultFor(call, readResults, writeResults)));
}
```

`runToolCallsPooledByTool` returns `PromiseSettledResult`s — a rejection still
needs a synthesized error tool message, or the next request has an unanswered
call.

### Pausing a run for human input

A tool that needs an answer returns a `suspend` directive on a **successful**
result. Pick the resume kind by whether the answer becomes the tool's result.

```ts
// The run resumes later via a prompt; the tool message is kept.
return {
  success: true,
  data: { askedAt: nowIso },
  suspend: { reason: "awaiting user selection", resumeKind: "wake", request: formSpec },
};

// The human's reply IS this call's tool result; the loop withholds the message.
return {
  success: true,
  data: null,
  suspend: { reason: "awaiting answer", resumeKind: "answer", request: formSpec },
};
```

If your runtime ends the whole run and starts a fresh one on reply, `wake` is
the kind you want — `answer` only pays off when you thread the reply back into
the same transcript as the matching `role:"tool"` message.

### Carrying disclosure across a run boundary

When a run ends and a later one continues the same conversation, persist the
**names** and re-validate on the way back in.

```ts
// End of run: names only, never plugin objects.
await db.saveActivation(conversationId, [...activePlugins]);

// Start of the next run.
const persisted = await db.loadActivation(conversationId);
const { active, dropped } = await rehydrateActivation(persisted, {
  canonicalizeName: (n) => registry.canonicalizeName(n),
  resolve: async (n) => (registry.get(n) ? true : "unknown"),
});
// Seed core, then add what survived — core plugins load unconditionally.
const activePlugins = new Set([...initialActivePlugins(registry.corePlugins()), ...active]);
for (const drop of dropped) log.info("plugin not restored", drop);
```

Without this, every boundary silently resets the agent to core-only and it
re-discovers from scratch — which costs a round-trip per resumption.

### Assembling a bounded run

The pieces compose; nothing here knows about the others.

```ts
const deadline = createRunDeadline({ timeoutMs: RUN_BUDGET_MS, label: "run" });
const heartbeat = createCoalescedHeartbeat({
  coalesceMs: 10_000,
  flush: () => db.touchRun(runId),
});
let stats = emptyRunStats();
let stopReason: StopReason = "done";

try {
  for (let i = 0; i < maxIterations; i++) {
    deadline.throwIfTimedOut();
    const { messages: healed } = validateAndHealMessages(transcript);
    const result = await turnFn(healed, wireTools, deadline.withExternal(cancelSignal));
    stats = accumulateTurn(stats, result);
    await heartbeat.beat();
    if (!result.message.tool_calls?.length) break;
    const suspend = await runToolBatch(result.message.tool_calls);
    if (suspend) {
      // The two pause reasons are separate values — see the stop-reason table.
      stopReason =
        suspend.resumeKind === "answer" ? "waiting_for_reply" : "resuming_later";
      break;
    }
  }
} catch (error) {
  stopReason = classifyRunFailure(deadline, error) === "timed_out" ? "deadline" : "aborted";
} finally {
  await heartbeat.beat({ force: true });
  deadline.dispose();
}
```

### Spawning child runs (sub-agents)

A sub-agent is not a special kind of thing. It is **a run that another run
asked for**, so everything the harness already gives a run applies unchanged:
its own deadline, its own heartbeat, its own `RunStats`, its own route plan.
Three things are genuinely new — lineage, admission, and waiting — and
`@juno-ai/bind/run` owns the decision in each. It owns none of the measuring:
counting runs in a chain needs your database, and judging whether a count is
too high does not.

**1. The spawn tool is an ordinary plugin.** Nothing special is needed here —
the tool vocabulary is already shared.

```ts
const orchestration: ToolPlugin<Ctx> = {
  name: "orchestration",
  description: "Delegate focused work to a child run.",
  icon: "share",
  tools: [
    {
      name: "spawn",
      description: "Run one focused objective as a child and return its id.",
      parameters: spawnArgsSchema, // z.object({ objective: z.string(), plugins: z.array(z.string()) })
    },
  ],
  async execute(toolName, args, ctx) {
    const { objective, plugins } = spawnArgsSchema.parse(args);

    const admission = admitChildRun([
      { kind: "depth", parentDepth: ctx.chain.depth, maxDepth: 5 },
      { kind: "chain_budget", runsInChain: await countRuns(ctx.chain), maxRuns: 50 },
    ]);
    if (!admission.admitted) {
      return { success: false, kind: "validation", error: admission.reason };
    }

    const childRunId = await queue.enqueueRun({
      objective,
      plugins,
      chain: descendChain(ctx.runId, ctx.chain),
    });
    return { success: true, data: { childRunId } };
  },
};
```

**2. Admission is the part worth getting right.** An agent that can spawn can
spawn agents that spawn. Bound it *before* enqueuing, not after — an unbounded
chain is a runaway spend, and the failure mode is silent.

Each rule carries its limit **and** the measurement it judges, so a bound you
configure but never wired a count for is not expressible. That shape exists
because the alternative fails quietly: a bounds object beside a facts object
lets a limit sit in config and never fire, and nothing looks wrong.

```ts
import { admitChildRun, descendChain, type ChainRule } from "@juno-ai/bind/run";

const rules: ChainRule[] = [
  { kind: "depth", parentDepth: chain.depth, maxDepth: 5 },
  { kind: "chain_budget", runsInChain: await countRunsInChain(chain), maxRuns: 50 },
  { kind: "pair_seen", alreadyPaired: await hasPairedInChain(chain, targetId) },
  { kind: "pair_cooldown", msSinceLastSpawn: await msSinceLastSpawn(runId), cooldownMs: 30_000 },
  { kind: "tenant_rate", runsInWindow: await countRecentRuns(tenantId), maxRuns: 100, windowMs: 30_000 },
  { kind: "tenant_ceiling", activeRuns: await countActiveRuns(tenantId), maxActiveRuns: 200 },
];

const admission = admitChildRun(rules);
if (!admission.admitted) {
  log.warn("child run refused", { rule: admission.rule, retryable: admission.retryable });
  return { success: false, kind: "validation", error: admission.reason };
}
```

Rules are evaluated in order and the first refusal wins, so you choose which
reason the model sees. Pick the set against your own cost model: depth caps
runaway recursion, a chain budget caps a chain that stays shallow but keeps
fanning out, `pair_seen` allows a given pair to work together once per chain
and a `pair_cooldown` merely spaces them out, a tenant rate limit bounds a burst
over a rolling window, and a tenant ceiling bounds what is running *right now* —
the last two covering what no chain rule can, someone starting a thousand
independent chains.

A refusal carries `rule` (which bound fired) and `retryable`, which separates a
bound that clears on its own — a cooldown, a rate window rolling — from one that
never will, so a caller can choose between waiting and giving up. Only a
refusal carries them; `retryable === undefined` means there was nothing to
retry, not "not retryable".

Two failure modes get opposite treatment, and the difference is the part to get
right. A **broken measurement** (`NaN`, `Infinity`, a negative count) refuses:
every comparison is false against `NaN`, so the naive reading would turn a
broken count into an unbounded chain. A bound you **could not measure at all**
— the count query threw — is expressed by *omitting the rule*, which admits.
Reaching for `NaN` to mean "unknown" collapses the two and makes a transient
database blip refuse every child run you have.

`descendChain` handles the lineage arithmetic, including the root-id fallback
that is easy to get backwards — a first-generation child adopts its parent's
*id* as the chain root, later generations keep the root the parent already
carries. Getting that wrong makes every generation its own chain, and every
per-chain bound then counts the wrong set and never fires.

**3. Waiting.** Two shapes work. Suspend the parent and let child completion
wake it, which frees the worker slot:

```ts
return {
  success: true,
  data: { childRunIds },
  suspend: { reason: "awaiting child runs", resumeKind: "wake", request: { childRunIds } },
};
```

…or poll inside the tool, which keeps the parent's transcript intact but holds
its slot — so budget the poll well under the parent's own deadline.
`createPollSchedule` is a doubling backoff bounded by that budget; it clamps
the final delay so a sleep can never overshoot the deadline you promised.

```ts
import { createPollSchedule } from "@juno-ai/bind/run";

const startedAt = Date.now();
const poll = createPollSchedule({
  initialDelayMs: 500,
  maxDelayMs: 30_000,
  budgetMs: 10 * 60_000,
});

while (true) {
  const children = await loadChildRuns(childRunIds);
  if (children.every((c) => c.finished)) return { success: true, data: { children } };

  const step = poll.next(Date.now() - startedAt);
  if (step.kind === "expired") {
    return { success: true, data: { children, timedOut: true } };
  }
  await sleep(step.delayMs, signal);
}
```

Elapsed time is an argument rather than something the schedule reads off a
clock, so a test can drive the whole backoff without waiting for any of it.

**4. Rolling results up.** `accumulateTurn` and `accumulateToolCall` fold a
run's *own* activity; `accumulateRun` folds one whole run into another, which
is what a chain's totals are made of.

```ts
import { accumulateRun, emptyRunStats } from "@juno-ai/bind/contracts";

const chainTotals = childStats.reduce(accumulateRun, parentStats);
```

The fold is associative, so a chain reduces in whatever order its children
finish. Two properties are worth expecting rather than debugging: `modelTimeMs`
will exceed the chain's wall-clock once children run in parallel (the sum is
what the chain *cost*, not how long it took), and `outputTokensPerSecond` is
recomputed from the merged totals rather than averaged across runs — averaging
two rates weights a 10-token run like a 10,000-token one.

---

## Roadmap

Named, not scheduled. Listed so a consumer can tell a deliberate omission from
an oversight.

- **Time-to-first-token in `RunStats`.** The loop now folds `RunStats` itself,
  so `modelTimeMs`, `toolTimeMs` and the per-tool breakdown come for free — but
  `ttftMs` lives on `TurnTimings` and only the transport can see the first byte.
  A `callModel` that reported its own timings back would close the gap;
  `ToolLoopTurn` would have to grow, which is a change to the shape every host
  already implements.
- **A streaming turn contract.** Half of this landed:
  `createTurnTextStream` owns the emit/retry interaction for assistant *text*,
  arms `producedOutput` itself, and repairs a retractable surface between
  attempts ([how-to](#how-to-stream-tokens-to-a-user-without-breaking-fallback)).
  What has not landed is the *contract*: `TurnFn` still returns one finished
  `ModelTurnResult`, so `runToolLoop` cannot see the stream and a host must
  thread it through its own `AttemptFn`. A loop-level streaming turn — where the
  kernel wires the stream and the reset lands without host cooperation — needs
  `TurnFn` to grow a streaming variant, and that is a breaking change to the
  package's central type. Deferred for that reason, not for lack of a design.
- **Streaming for tool calls and reasoning.** `createTurnTextStream` handles
  text only, deliberately. Reasoning is a product judgement the host expresses
  by what it forwards; tool-call deltas are not output at all until dispatch.
  Neither has a natural primitive yet, and a host that renders a tool call as it
  is being assembled reads `assembled()` off `createToolCallAccumulator`
  mid-stream today, which works.

---

## Rules for automated contributors

*Invariants of this package. Read this before changing anything under `src/`;
each is enforced by lint, typecheck, or CI where it is developed.*

1. **Never import `@/*`, a Node builtin, `process`, or a framework.** Take a
   port instead. An ESLint block scoped to `packages/bind/**` enforces this.
2. **Never add a runtime dependency.** New third-party code must be a peer
   dependency, and only with a strong reason. `openai` is type-only.
3. **Keep every module I/O-free.** If a change needs a clock, a random source,
   the environment, or a network call, take it as an explicit input. A
   `Date.now` default on an injectable clock is the only sanctioned exception.
4. **Stay deterministic.** Identical inputs must produce identical plans.
   Nothing about registration order, map iteration, or wall-clock time may
   reorder candidates.
5. **Do not add module-level mutable state.** Export a factory. Module state is
   per-isolate on edge runtimes and leaks between tests.
6. **Do not collapse a generic into a concrete host type.** `TCtx` and
   `TContentPart` are parameters because hosts genuinely diverge there; binding
   them to one application's types would fork the package.
7. **Version the policy, do not mutate it.** If routing order or meaning
   changes, bump `ROUTE_POLICY_VERSION`.
8. **Put tests in `src/**/__tests__/`.** They run under bare `bun test` with no
   DOM and no database. Anything needing either belongs in the host.
9. **Do not edit `version` in `package.json`.** It is a placeholder; the
   published version is stamped at release time.

---

## Versioning

**Versioning is not semver.** Each published release increments the major and
resets the rest — `1.0.0`, `2.0.0`, `3.0.0` — so the major is a release counter,
not a compatibility signal, and a bump does not by itself mean the surface
changed. Pin an exact version and read the changes between releases until this
stabilizes.

## Development

The source lives in Monad's canonical repository. `juno-ai-labs/agent-harness`
on GitHub is a read-only **archive mirror** of it, exported via Copybara, and
npm releases are published from the canonical repository rather than from either
mirror. Issues are welcome on the GitHub mirror; code changes land in the
canonical repo and flow out with the next export.

Run the tests with [Bun](https://bun.sh):

```sh
bun test
```

## License

[MIT](./LICENSE)
