Docs
The reference. Every call, every field, every code the SDK can hand back — and the HTTP surface underneath it, for fleets that do not run JavaScript.
- SDK v0.4.2
- SPEC v0.4
- MIT
- NODE 20+
Contents
Overview
WHAT THE SDK ACTUALLY DOESThe SDK is a thin shell around four verbs. It never generates a key, never holds a balance, and never asks a server for permission — it asks the machine to sign, and hands the signature on.
Install
NO ACCOUNT · NO KEY TO PASTEThe package ships ESM and CJS entry points and its own type declarations. It has one runtime dependency — a COSE signer shim — and no native build step.
- npmnpm i @katana/sdk
- pnpmpnpm add @katana/sdk
- bunbun add @katana/sdk
- denodeno add jsr:@katana/sdk
Environments
TWO, AND THEY DO NOT SHARE IDENTITIESA machine attested on testnet is not attested on mainnet. Manufacturer roots are published per network, so a test key can never settle real work by accident.
| Network | Chain ID | Endpoint | Settlement | Faucet |
|---|---|---|---|---|
| mainnet | katana:1 | https://rpc.katana.sh | real | — |
| testnet | katana:11 | https://rpc.test.katana.sh | valueless | https://faucet.test.katana.sh |
Quickstart
THE SHORTEST PROGRAM THAT SETTLESAttach, take a grant, open against a dock, let the meter run, close. Everything else in this manual is a detail of one of those five lines.
import { attach, secureElement } from "@katana/sdk";
const machine = await attach({
did: "did:katana:p256:KL-0117",
signer: secureElement(),
});
await machine.attest();
const grant = await machine.grant({
scope: ["arm.lift", "arm.place"],
ceiling: { wh: 8_000, currency: "usd", max: 4.0 },
expiry: "+45m",
});
const session = await grant.open({ dock: "dock-01" });
for await (const tick of session.meter()) {
telemetry.push(tick); // { wh, ts, sig }
}
const receipt = await session.close();
console.log(receipt.amount.usd); // 0.4521The object model
FIVE NOUNS, AND WHAT OWNS WHATEach object below is created by the one above it and cannot outlive it. That is the whole hierarchy — there is no registry, no session store, and nothing to garbage-collect on our side.
Machine
└── Grant scope, ceiling, expiry
└── Session one dock, one engagement
├── Tick { wh, ts, sig } ≤ 1s apart
└── Claim signed running total
└── Receipt the close, on chainSession lifecycle
SIX STATES · ONE DIRECTIONA session only moves forward. There is no pause, and nothing re-opens — a dock that drops out ends the session and the next engagement is a new one.
Scopes
EXPLICIT, AND NO WILDCARD ON MOTIONA scope names one actuator verb. `arm.*` does not compile, because nobody has ever meant it — a fleet that wants two verbs asks for two verbs.
| Scope | Grants |
|---|---|
| arm.lift | Vertical actuation on a fixed arm |
| arm.place | Horizontal actuation and release |
| arm.weld | Arc actuation. Requires a second, live attestation |
| drive.move | Wheeled or tracked translation |
| deck.hold | Load retention while driving |
| dock.draw | Requesting power from a dock |
attach()
SDKBinds the SDK to a key that already exists in the machine's secure element. Makes no network call.
function attach(options: AttachOptions): Promise<Machine>- E_SIGNERThe secure element did not answer, or answered with a different curve than the DID declares.
const machine = await attach({
did: "did:katana:p256:KL-0117",
signer: secureElement(),
network: "testnet",
});machine.attest()
SDKProves the key is real and its manufacturer root is current. The result is cacheable for its stated lifetime.
machine.attest(options?: AttestOptions): Promise<Attestation>- E_ATTESTThe key answered but the chain did not verify. Usually a manufacturer root that has rotated.
const proof = await machine.attest({ audience: "dock:dock-01" });
// { ok: true, chain: "verified", expiresAt: 1782416671, cose: "…" }machine.grant()
SDKIssues scoped, capped, time-boxed authority. The grant is the only thing that can authorise an actuator call.
machine.grant(options: GrantOptions): Promise<Grant>- E_SCOPEA scope entry is unknown, or contains a wildcard.
- E_ATTESTNo valid attestation is held and one could not be obtained.
const grant = await machine.grant({
scope: ["arm.lift", "arm.place"],
ceiling: { wh: 8_000, currency: "usd", max: 4.0 },
expiry: "+45m",
region: "eu-west",
});
grant.on("revoked", ({ by, at }) => machine.park({ reason: "revoked" }));grant.open()
SDKOpens a metered session against one dock, by mutual attestation. Both sides sign the start reading.
grant.open(options: OpenOptions): Promise<Session>- E_SCOPEThe dock is outside the grant's region, or offers no scope the grant covers.
- E_EXPIREDThe grant died between issue and open.
const session = await grant.open({
dock: "dock-01",
signal: AbortSignal.timeout(5_000),
});session.meter()
SDKAn async iterable of signed deltas. Yields at most one second apart while the session is open.
session.meter(): AsyncIterable<Tick>Takes no arguments.
- E_CEILINGThe meter reached the grant's ceiling. Expected, not exceptional — close and re-grant.
for await (const tick of session.meter()) {
// a tick more than a second late is dropped, never interpolated
telemetry.push(tick);
if (tick.wh > 500) console.warn("draw spike", tick);
}session.close()
SDKSubmits the final claim and settles the channel in one transaction, whatever the tick count.
session.close(options?: CloseOptions): Promise<Receipt>- E_EXPIREDThe grant died before the close and no signed claim exists to settle.
const receipt = await session.close();
// {
// session: "sx_01J8Y…", total_wh: 3417,
// amount: { usd: 0.4521 }, ticks: 412,
// settled: { tx: "0x9c1f…", at: 1782413071 },
// proof: { machine, dock, grant, sig }
// }grant.revoke()
SDKEnds authority immediately and publicly. Accounting for work already done is unaffected.
grant.revoke(reason?: string): Promise<void>- E_EXPIREDThe grant had already died. Revoking a dead grant is a no-op, not an error, unless it never existed.
await grant.revoke("axis 4 torque outside envelope");
// authority ends at the next actuator call, not at the next syncTypes
THE SHAPES, IN FULLtype Signer = {
curve: "p256" | "ed25519";
sign(payload: Uint8Array): Promise<Uint8Array>;
};
type Attestation = {
ok: boolean;
chain: "verified" | "stale";
expiresAt: number; // unix seconds
cose: string; // COSE_Sign1, base64url
};
type Ceiling = {
wh: number; // hard energy cap
currency: "usd" | "eur";
max: number; // hard spend cap
};
type Tick = {
wh: number; // delta since the previous tick
ts: number; // unix millis, dock clock
sig: string; // dock signature over (session, wh, ts)
};
type Receipt = {
session: string;
total_wh: number;
amount: Record<Ceiling["currency"], number>;
ticks: number;
settled: { tx: string; at: number } | null; // null while draining
proof: {
machine: string; // did
dock: string; // did
grant: string;
sig: string;
};
};
type SessionState =
| "opening" | "open" | "draining"
| "closing" | "settled" | "voided";HTTP API
FOR FLEETS THAT DO NOT RUN JAVASCRIPTThe SDK is a client for this surface and nothing more. Every call below is what the SDK sends; a machine that can do HTTPS and COSE needs no SDK at all.
| Method | Path | Does |
|---|---|---|
| POST | /v0.4/attest | Verify a COSE_Sign1 proof against the current root set |
| POST | /v0.4/grants | Issue a scoped grant |
| DELETE | /v0.4/grants/{id} | Revoke a grant |
| POST | /v0.4/sessions | Open a session against a dock |
| POST | /v0.4/sessions/{id}/ticks | Submit a batch of signed ticks |
| POST | /v0.4/sessions/{id}/close | Submit the final claim and settle |
| GET | /v0.4/sessions/{id} | Read state and the running total |
| GET | /v0.4/docks/{id} | Read a dock's DID, region and port count |
curl -s https://rpc.katana.sh/v0.4/sessions \
-H "Authorization: COSE $(katana sign --body @open.json)" \
-H "Content-Type: application/json" \
-d @open.json
# {"session":"sx_01J8Y…","state":"open","t0":1782411204}Webhooks
FOUR EVENTS · SIGNED THE SAME WAY EVERYTHING ELSE ISDeliveries are retried for 24 hours with exponential backoff, then dropped. A missed delivery is never a lost settlement — the receipt is on chain regardless.
import { verifyWebhook } from "@katana/sdk";
app.post("/hooks/katana", raw(), async (req, res) => {
const ok = await verifyWebhook({
body: req.body, // Buffer, not parsed
signature: req.header("X-Katana-Signature"),
network: "mainnet",
});
if (!ok) return res.status(400).end();
const event = JSON.parse(req.body.toString());
if (event.type === "session.settled") await reconcile(event.data);
res.status(204).end();
});CLI
SHIPPED WITH THE PACKAGEThe CLI is the SDK with a terminal in front of it. It signs with the same secure element and refuses in the same places.
| Command | Does |
|---|---|
| katana attest | Prove the local machine and print the COSE blob |
| katana grant --scope arm.lift --ceiling 8kWh --expiry 45m | Issue a grant and print its id |
| katana open --dock dock-01 --grant gr_… | Open a session and stream ticks to stdout |
| katana close --session sx_… | Settle and print the receipt as JSON |
| katana revoke --grant gr_… --reason '…' | End authority now |
| katana sign --body @file.json | Produce an Authorization header for the HTTP API |
| katana doctor | Check signer, clock drift and root freshness |
Error codes
EVERY REFUSAL THE SDK CAN RAISEErrors are `KatanaError` instances with a `code`, a `retryable` flag and, where one exists, a `hint`. Nothing here falls back to a degraded path.
Limits
THE NUMBERS THAT WILL STOP YOURecipes
THREE THINGS PEOPLE ASK FORRotate a key without breaking lineage
Rotation keeps history. The new epoch cites the old key, so an auditor can walk the chain backwards without the manufacturer's help.
const next = await machine.rotate({
reason: "scheduled",
// the previous public half is cited, never discarded
});
// old attestations stay verifiable; new grants use the new epoch
await machine.attest({ force: true });Split one job across two machines
Two grants, one memo. Each machine settles its own half, and the shared memo is what reconciles them later.
const memo = `job:${jobId}`;
const [lift, place] = await Promise.all([
armA.grant({ scope: ["arm.lift"], ceiling, expiry: "+20m", memo }),
armB.grant({ scope: ["arm.place"], ceiling, expiry: "+20m", memo }),
]);
// each settles independently; the memo joins them in the ledger
const receipts = await Promise.all([runLift(lift), runPlace(place)]);Reconcile a period against the chain
Never reconcile against your own telemetry. The receipts carry the proof; your database carries whatever your process wrote down.
const settled = await katana.receipts.list({
machine: "did:katana:p256:KL-0117",
from: "2026-08-01",
to: "2026-09-01",
});
for (const r of settled) {
const local = await db.receipts.find(r.session);
if (!local) report.missing.push(r);
else if (local.total_wh !== r.total_wh) report.drift.push({ r, local });
}Versioning
SLOW, DATED, AND BORING ON PURPOSEThe SDK follows semver. The protocol does not — it is dated, and two spec versions run side by side for a full quarter before the older one is refused.
Glossary
THE WORDS THIS MANUAL USES PRECISELY$KATANA
ONE NUMBER SETTLED · THE REST UNPUBLISHEDA supply of 500,000,000 $KATANA, listed on pons. The supply figure and the listing are what exist; the rows below marked unpublished are genuinely undecided rather than withheld, and this page will not guess at them.
KAINothing in here is advice. It is the shape of the thing, written down... If a field is missing, the call will tell you so before I do.

