Getting started /Quickstart: TypeScript

Quickstart: TypeScript

Gate decisions and verify receipts from Node with @hesohq/sdk, in a few lines. The verifier and the gate run in-process. No subprocess, no re-implementing the crypto in JavaScript.

Install

Add the SDK to a Node project (Node 18 or later). @hesohq/sdk binds to the native @hesohq/node addon for signing and hashing.

bash
npm install @hesohq/sdk @hesohq/node

To verify receipts in a browser instead, use @hesohq/verify-wasm. It is the same Rust core compiled to WebAssembly, so the verdict is byte-identical.

Verify a receipt

This is the most common Node job: you have an Action Receipt and you want to know if it is genuine. Call init() once to load the WASM module, then pass verifyActionReceipt the raw receipt bytes — encode the receipt JSON with a TextEncoder. It recomputes the hash and re-checks the signatures, then returns a verdict and a re-derived trust level.

verify.tsts
import init, { verifyActionReceipt } from "@hesohq/verify-wasm"
import { readFileSync } from "node:fs"

await init() // load the wasm once, up front

const receipt = JSON.parse(readFileSync("receipt.json", "utf8"))

// The verifier takes the raw receipt bytes, not the object.
const bytes = new TextEncoder().encode(JSON.stringify(receipt))
const result = verifyActionReceipt(bytes)

console.log(result.verdict)     // "Valid"
console.log(result.trust_level) // "L0" or "L1"

if (result.verdict === "Valid") {
  proceed()
}

A "Valid" verdict means the receipt passed every gate: the algorithm and version are recognized, the recomputed hash matches, and the signatures verify. The trust_level is "L0" (operator-signed) or "L1"(operator plus a human approver’s co-signature), re-derived from the signatures that actually pass. It is only meaningful when the verdict is "Valid".

A tampered receipt

Change any signed field and the recomputed hash stops matching the signature. The verdict names the first gate that failed.

ts
// Change one byte of a signed receipt, then verify it.
receipt.content.action.amount_usd = 1

const bytes = new TextEncoder().encode(JSON.stringify(receipt))
const result = verifyActionReceipt(bytes)

console.log(result.verdict) // "HashMismatch" — the bytes no longer match the signature

The verdict is one of:

  • Valid — passed every gate.
  • HashMismatch — a signed field was changed after signing.
  • InvalidSignature — a signature did not verify (e.g. InvalidSignature:operator).
  • WrongAlgorithm — the receipt uses an algorithm this build does not support.
  • TrustLevelMismatch — the receipt claims a higher trust level than its signatures support.
  • Malformed — the receipt could not be parsed.

Some verdicts carry a :detail suffix naming the exact thing that failed. Trust is never read off the wire: a receipt that claims L1 but carries only an operator signature verifies as TrustLevelMismatch, not L1. Read Offline verification for the full gate order.

Gate an action

The other half of the job is deciding whether an action is allowed before you run it. gate runs an action through your active policy and returns one of four decisions: allow, block, redact, or require_approval.

gate.tsts
import { gate } from "@hesohq/sdk"

const action = {
  verb: "payment",
  amount_usd: 4200,
  recipient: "acct_993",
}

const decision = gate(action)

if (decision === "allow") {
  applyTransfer(action)
} else {
  // "block" | "redact" | "require_approval"
  console.warn("not allowed:", decision)
}

When you would rather halt than branch, use assertGate. It throws unless the decision is allow, so the line after it only runs on authorized actions.

apply.tsts
import { assertGate } from "@hesohq/sdk"

// Throws unless policy returns "allow". Past this line, the action is authorized.
assertGate({ verb: "payment", amount_usd: 4200, recipient: "acct_993" })

applyTransfer()

Call init() once at startup to load your policy and operator key before the agent runs. See Policy & decisions for how each decision is chosen, and Trust levels for the L0/L1 distinction.

Gate an LLM client

wrap returns a guarded copy of a client. Each call is gated as an llm_call, signed into a receipt, and blocked if policy says so — without touching your call sites.

wrap.tsts
import { wrap } from "@hesohq/sdk"
import Anthropic from "@anthropic-ai/sdk"

// Every messages.create call is gated as an llm_call and signed into a receipt.
const client = wrap(new Anthropic())

const out = await client.messages.create({
  model: "claude-sonnet-4-5",
  max_tokens: 1024,
  messages: [{ role: "user", content: "draft the refund email" }],
})

To mirror receipts to the cloud and pull your deployed policy, the cloud client is a one-liner. The server re-verifies every receipt before mirroring it.

cloud.tsts
import { pushReceipt, pullPolicy } from "@hesohq/sdk"

await pushReceipt(process.env.HESO_API_KEY!, receipt) // server re-verifies, then mirrors
const policy = await pullPolicy(process.env.HESO_API_KEY!)

The full surface — wrap options, the cloud client, approvals, and the wire types — lives in the TypeScript SDK reference.

What a receipt proves — and what it doesn’t

A valid receipt proves the operator authorized this action under a known policy, and at L1 that a person approved it with a device-held key. It records what was authorized, not whether the action succeeded downstream.

Next steps