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

@canopyhq/sdk

v0.2.1

Published

The Canopy SDK for TypeScript: declare tools once, propose the app document, bind functions, and open governed sessions.

Readme

Canopy for TypeScript

bun add @canopyhq/sdk zod

zod is the SDK's schema language and its only runtime dependency, taken as ^4.6.1 so a project on a newer 4.x resolves one shared copy rather than nesting a second one. Add zod to your own project too: a schema is recognised by what it can do, not by which copy built it, but one copy is what you want.

A tool is declared once, in code. declare is pure data — a description and two schemas, no function. appdoc turns a record of declarations into the app document you propose, taking each tool's name from its record key. bind supplies the functions for one context. Nothing is written twice, and nothing is generated.

Quickstart

Sign in to the console, add an inference option for your tenant, then run this:

import * as z from "zod";
import { Canopy, appdoc, bind, declare, propose, type Bindings } from "@canopyhq/sdk";

// One declaration per tool: what it does and the shape of its arguments and result.
const tools = {
  refund: declare({
    description: "refund a payment",
    input: z.object({ amount: z.int(), customer: z.string() }),
    output: z.object({ refunded: z.int() }),
  }),
};

// The app document is a function of the declarations; the record keys name the tools.
const document = appdoc({ name: "payhq", description: "moves money" }, tools);

// Propose it once. Open the consent URL in the console you are signed in to;
// the tenant's grant mints the install key.
const proposal = await propose(document, { url: "http://localhost:3000" });
console.log("consent at", proposal.consentUrl);
const { key } = await proposal.granted();

// A factory binds the same declarations to one context's functions.
const refunds = (ledger: Ledger): Bindings<typeof tools> => bind(tools, {
  refund: async ({ amount, customer }) => ({ refunded: await ledger.refund(customer, amount) }),
});

const canopy = new Canopy({ url: "http://localhost:3000", key });
const session = await canopy.open({
  principal: "user:aiden",
  instructions: "Help customers with their payments.",
  tools: refunds(ledger),
});
const turn = await session.send("refund c-9 five dollars", {
  signal: AbortSignal.timeout(30_000),
});
console.log(turn.reply, turn.finish);
await session.close();

url defaults to https://api.canopyhq.dev; name a self-hosted door with it. send waits for the whole turn: tool calls, their answers, and the final reply.

open carries what this session dials with: instructions for its standing direction, params for the generation settings, and rounds for the cap on model rounds in one turn — a positive integer, which an agentic session that works through many tool calls raises above the door's default of ten. Omit it and the door's default stands; a turn that reaches the cap finishes rounds.

The tenant picks the models an install may run on: consent selects a set of presets and names a default. await canopy.appdoc() reads the install's document with that set as inferences, each preset's id, name, provider kind, model and compact_at, with exactly one marked default; no secret and no secret's name is ever read there. open({ inference: "fast" }) runs the session on the preset with that id, and omitting it runs on the default. A preset outside the consented set raises InferenceNotAllowed, naming it in error.inference, and nothing opens. The preset's generation defaults sit under params: every field the session sets overrides the preset's, and the session's opening record shows the preset it runs on and the parameters it landed with. A session's preset is fixed at open; a different one is a new session.

Declare, derive, bind

declare({ description, input, output }) carries the schemas and the argument and result types they describe. timeout: "5s" and result_limit: "16KiB" may ride beside them. A schema is a zod schema or a plain JSON Schema object; a JSON Schema object is carried into the document exactly as written and declares any for its argument and result types, so an existing document ports over unchanged.

appdoc({ name, description }, tools) renders the document: appdoc: 1, the tools in record key order, each schema rendered by z.toJSONSchema. A zod object strips unknown keys, so the input schema it renders permits them and the output schema it renders does not. Propose the document with propose(document) or canopy.propose(document), and propose a replacement for an existing install with canopy.update(document).

Bindings<typeof tools> is the record type the declarations imply: one function per declared tool, taking the arguments the input schema describes and returning what the output schema describes. A missing binding, an undeclared one, or a wrong argument type is a compile error. Take typeof tools from the record itself and let inference do the rest: annotating the record as Declarations first erases which schemas are which, and every binding widens to any. Type a factory with it — executor(db), a test double, a request-scoped handler — and every context binds the same declarations:

const production = refunds(ledger);
const rehearsal = refunds(new Ledger.InMemory());

bind(tools, functions) returns that record with the schemas applied: arguments are checked before your function runs and its result after, both against the declaration. A value that does not match raises InvalidToolValue, whose message names the tool and how many positions failed and whose detail names the positions; the message is what a record may carry, and detail stays in this process, so keep it out of anything you rethrow. A zod-declared result is passed on as its output schema parsed it, so undeclared fields never leave the process; a result declared by a JSON Schema literal is checked but passed on exactly as your function returned it, undeclared fields and all. Binding plain functions instead of a bound record still works, and behaves as it always has: names are checked at open, and a result that does not match the document's output schema logs a warning and still completes.

Supplying tools starts this process as an executor: every declared dial tool needs a function, and extra or missing bindings fail before a turn starts, checked against the document the door holds. Omit tools to open or attach as a client without running an executor. Pass logger: { warn, debug } to Canopy to use your application's logger. The default writes warnings to stderr and drops debug lines, so the SDK never touches your stdout; a CLI or TUI that owns stdout needs no override, and a caller who wants the debug stream passes a logger that keeps it.

Pass patience: milliseconds to Canopy to say how long a live tail rides out a door that has gone dark; sessions inherit it, and the default is 60 seconds. A deployment recreates the door's container and it answers nothing for seconds: the stream cuts, the reconnect is answered by whatever stands in front of the door, and so is the status read behind it. A tail reading the session's present — events() or records() with no to, and so the send() that waits on its turn — reconnects through that with a backoff from 100 ms to 5 seconds, and surfaces Unavailable or ConnectionFailed only once patience has run out since the outage began; a record or a clean end starts the clock over. patience: 0 surfaces the first failure. Every other failure still leaves at once, and a bounded read — one with a to — never retries.

Pass onRequest: ({ method, path, status, ms }) => { ... } to Canopy to observe each door request once at response headers (including streams), with ms measuring elapsed time to headers, status 0 on fetch failure, and observer exceptions ignored.

Watch a conversation

Use the same client and tools to open a session, then consume its events:

const session = await canopy.open({ principal: "user:aiden", tools: refunds(ledger) });
const pump = (async () => {
  for await (const event of session.events()) {
    switch (event.type) {
      case "reply": console.log(event.content); break;
      case "turn.ended": console.log(event.finish); break;
      case "archived": return;
    }
  }
})();
const sent = session.send("refund c-9 five dollars");
await session.steer("only if the order is under 30 days old");
await session.idle();
await sent;
await session.close();
await pump;

steer adds direction to the active turn. If the turn has already ended, or none is live, the text lands as an ordinary message; the event script permits that race. cancel() asks a live turn to stop; aborting the signal passed to send sends the same control. compact() asks the harness to summarize the conversation so far before its next inference: on a live turn that is before the next round, on an idle session the head of the next turn. A conversation too short to summarize is left alone.

adjust() changes what every request from here on carries, without opening a new session and without rewriting the dial the session was opened with:

await session.tray(`The user is on the checkout page. It is ${new Date().toISOString()}.`);
await session.effort("high");
await session.adjust({ tray: null, params: { temperature: 0.2 } });

The tray is context the door frames onto the instructions of every request — a trailing section under a heading the door chooses, so it is never a message the model can quote back as something you said, it never reaches a compaction summary, and it never accumulates in the transcript. Exactly one is ever in force: a second tray replaces the first. It is capped at 16 KiB.

Params override what the session was dialed with, field by field: a field the override states wins, and one it does not leaves the dial's. effort is how much the model deliberates before it answers — low, medium, high, xhigh or max — and it rides params at open() too. Compaction requests ignore both the tray and the override.

On adjust({ tray, params }) a field you leave out keeps what stands and null clears it, so adjust({ tray: "…" }) does not disturb the params and adjust({ params: null }) returns to the dial's. tray(text) and effort(level) are the one-field sugar, and null clears either.

idle() waits for the session's work to settle. close() waits for running local tools to post their terminal, then archives the session and stops its executor. Its records remain readable, and another send raises SessionArchived.

Pick up where you left off

Save the session ID and your last event's cursor. Attach eagerly, then resume at the next record position:

const session = await canopy.session(savedId, { tools: refunds(ledger) });
for await (const event of session.events({ from: savedCursor + 1 })) {
  console.log(event);
  if (event.type === "archived") break;
}

Record cursors are one-based: the first record is 1, and from: 0 names the origin, the position before the first record, so it reads the whole log exactly as from: 1 does. Negative or fractional cursors, and to: 0, raise InvalidCursor.

Without to, reads follow from from (record 1 by default). Events do not fetch earlier records to establish context: begin at a message cursor when you need that turn’s complete summary. With to, they stop before that cursor, clamped to the head returned by one initial status() request. Both records() and events() always use the principal token; binding tools never broadens that view. Only the executor's internal tail sees its addressed grants.

Attach without bindings when another process runs the tools:

const reader = await canopy.session(savedId);
console.log(await reader.status());

For a finite read:

for await (const record of session.records({ from: 1, to: 20 })) {
  console.log(record);
}
const status = await session.status();
const page = await canopy.sessions({ principal: "user:aiden", limit: 20 });
const next = page.next
  ? await canopy.sessions({ principal: "user:aiden", limit: 20, after: page.next })
  : undefined;

Turns, rounds and calls report only what landed records say. Every call has args: { sealed: true } and result: { sealed: true }, even when this process ran the tool. outcome describes its recorded terminal or denial; a sealed marker alone does not say that a result has landed. Rejected arguments are sealed to the inference role, which opens its own copy when reconstructing the model’s conversation. projections contains only the fields the policy recorded, and digest commits to the arguments. A verdict remains absent until this reader can observe it.

To see your own tool traffic, use the optional onCall hook on open or attach:

const session = await canopy.open({
  principal: "user:aiden",
  tools: refunds(ledger),
  onCall: ({ key, tool, args, result }) => console.debug(key, tool, args, result),
});

This hook reports as it runs, not as it landed. It receives the function's arguments and { ok: value } or { error: message } after the function settles, before its completion is posted. The SDK does not await an observer's promise. It never changes an event or a Turn; an observer failure logs a warning and leaves the tool outcome alone. Attach, resume and another process all reconstruct the same record-derived call shapes.

Unclaimed tools resume after a client-process restart. Already claimed calls await their original executor or configured deadline; a turn cancellation does not recall released work.

Each emitted event is a snapshot. A call’s projections contains the recorded projected fields, or undefined for older records without those fields; the projection digest is never substituted. turn.ended.replyCursor is a record position, while Turn.reply is reply text. turn.ended.summary contains the complete record-derived Turn; send() resolves that same summary.

An unknown ID raises SessionNotFound when you attach. Lists contain directory entries; ask status() for the current turn, round, and phase. status().context is what the last completed round was told — { tokens, compactAt, at }, or null before the first completion — against the threshold the session's inference option compacts at; compact() is how you ask for it earlier. A compaction leaves tokens standing until the next completion measures again, which at against head says. The returned subject is the filed subject identifier, absent for an unfiled stream. The principal filter accepts the original principal name.

Install for another tenant

When the customer owns the tenant, send its administrator a consent link. Proposing needs no key of yours (propose is a plain function; the client has the same method); the resulting key belongs to the new install, so create a client for it.

import { Canopy, propose } from "@canopyhq/sdk";
import { document, refunds } from "./tools.ts";

const proposal = await propose(document);
console.log("Ask your administrator to open", proposal.consentUrl);
const { key } = await proposal.granted();
const customer = new Canopy({ url: "https://canopy.example.com", key });
const session = await customer.open({ principal: "user:42", tools: refunds(customerLedger) });
console.log((await session.send("hello")).reply);
await session.close();

The same declarations bind for each tenant; only the state the factory closes over differs. Store the returned key yourself — the SDK reads no configuration file and always takes an explicit key.

Development

Run bun install, bun test, and bun run typecheck in this package. bun run build emits the Node-compatible ESM package into dist/; package import uses that build on Node and source on Bun. The SDK source has no Bun runtime dependency. The unit suite includes a native Node proposal and polling smoke test; packaging builds the JavaScript and declarations first. test/bindings.types.ts never runs: it pins with @ts-expect-error that a missing, extra or mistyped binding fails tsc. Regenerate the wire types with bun scripts/openapi.ts http://localhost:3000/openapi.json; the e2e stage runs the same script with --check against the door it boots, so a stale client fails the gate, and the script refuses a document that references a schema it does not define. Run the integration paths from . with bun e2e/run.ts.

Types

type Declaration<Input, Output> = {
  description: string; input: Input; output: Output;
  timeout?: string; result_limit?: string;
};
type Bindings<D> = {
  [K in keyof D]: (args: Args<D[K]>) => Result<D[K]> | Promise<Result<D[K]>>;
};
type Turn = {
  cursor: number;
  finish: "stop" | "length" | "rounds" | "failed" | "cancelled";
  error?: string;
  rounds: Round[];
  reply?: string;
  get calls(): Call[];
};
type Round = { cursor: number; content?: string; calls: Call[] };
type Sealed = { readonly sealed: true };
type Outcome = {
  kind: "completed" | "failed" | "expired" | "cancelled" | "denied" | "rejected";
  error?: string;
};
type Call = {
  key: string; providerId: string; tool: string;
  digest: string; projections: unknown; args: Sealed; result: Sealed; outcome?: Outcome;
} & (
  | { decision?: "allowed" | { denied: string } }
  | { rejected: string }
);
type CallTraffic = {
  key: string; tool: string; args: unknown;
  result: { ok: unknown } | { error: string };
};
type Effort = "low" | "medium" | "high" | "xhigh" | "max";
type Adjustment = { tray?: string | null; params?: Partial<GenerationParams> | null };
type SessionRef = { id: string; subject?: string; opened: string; archived: boolean };
type SessionStatus = {
  id: string;
  archived: boolean;
  head: number;
  live: { turn: number; round: number; phase: "thinking" | "calling" | "retrying" } | null;
};

Record, Dial, app document, and consent types come from the door's OpenAPI. The event vocabulary is message, turn.round, tool.called, tool.denied, tool.result, reply, turn.ended, and archived. Live text deltas will be available when the door supports them.