@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 zodzod 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.
