npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@juno-ai/bind

v16.0.0

Published

Agent harness: the tool-calling turn kernel, deterministic LLM provider routing with transport-error classification, the streaming-completion watchdog, run mechanics, sub-agent lineage and admission, transcript healing, tool-schema sanitization, the plugi

Readme

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.

This document is organised on the Diátaxis 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 for the contracts those types cannot express, Usage scenarios for porting shapes, and 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 | | Get something running end to end | Tutorial | | Solve one specific problem | How-to guides | | Know which entry point to use, and what holds between calls | Reference | | Know how versions work before installing | Versioning | | Port an existing agent runtime onto this | Usage scenarios | | Know what is deliberately not here yet | Roadmap | | Change this package safely | 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).

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

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.

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.

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.

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, or add progressive tool disclosure with createToolRegistry.


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.

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.

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.

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.

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:

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:

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.

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.

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.

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.

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.

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.

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.

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?

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:

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

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

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.

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

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.

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:

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:

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:

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:

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

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:

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

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.

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