TypeScript SDK
The TypeScript SDK is byte-for-byte interoperable with Go, Python, Rust, and C/C++. It runs in
Node 20+ and in modern browsers — the cryptographic primitives use audited pure-JS implementations from
the Noble suite (@noble/ed25519, @noble/hashes) plus @noble/post-quantum for ML-DSA-65.
No native dependencies, no WebAssembly required.
Stability: every primitive on this page is stable in 1.0.0-alpha.13. There are no
preview / experimental APIs in the snippets below.
Install
Section titled “Install”Three minutes, end to end
Section titled “Three minutes, end to end”import { PROTOCOL_VERSION, SCOPE_MEETING_ATTEND, SCOPE_MEETING_SPEAK, generateAgent, generateChallenge, generateHumanRoot, issueDelegation, signChallenge, verifyBundle, type DelegationCert, type ProofBundle,} from "@identities-ai/ratify-protocol";
// 1. Alice generates her hybrid (Ed25519 + ML-DSA-65) root identity.const { root: alice, privateKey: alicePriv } = await generateHumanRoot();
// 2. Her AI agent generates its own hybrid keypair.const { agent, privateKey: agentPriv } = await generateAgent( "Alice's Scheduler", "custom",);
// 3. Alice signs a delegation cert for the agent.const now = Math.floor(Date.now() / 1000);const cert: DelegationCert = { cert_id: "cert-001", version: PROTOCOL_VERSION, issuer_id: alice.id, issuer_pub_key: alice.public_key, subject_id: agent.id, subject_pub_key: agent.public_key, scope: [SCOPE_MEETING_ATTEND, SCOPE_MEETING_SPEAK], issued_at: now, expires_at: now + 7 * 24 * 3600, signature: { ed25519: new Uint8Array(0), ml_dsa_65: new Uint8Array(0) },};await issueDelegation(cert, alicePriv);
// 4. Verifier issues a challenge. Agent signs it.const challenge = generateChallenge();const challengeAt = Math.floor(Date.now() / 1000);const challengeSig = await signChallenge(challenge, challengeAt, agentPriv);
// 5. Agent assembles a proof bundle.const bundle: ProofBundle = { agent_id: agent.id, agent_pub_key: agent.public_key, delegations: [cert], challenge, challenge_at: challengeAt, challenge_sig: challengeSig,};
// 6. Verifier runs the verifier.const result = await verifyBundle(bundle, { required_scope: SCOPE_MEETING_ATTEND,});
if (result.valid) { console.log("✓ Authorized"); console.log(" human_id: ", result.human_id); console.log(" agent_id: ", result.agent_id); console.log(" granted_scope: ", result.granted_scope); console.log(" identity_status:", result.identity_status);} else { console.log("✗ Rejected:", result.identity_status, "—", result.error_reason);}Real output (IDs are derived from the freshly generated keys, so they’ll differ on every run):
✓ Authorized human_id: 996dc9a99007e7239b20d08bc845337b agent_id: c7f98cc2bc4fcb492147b08aa887a470 granted_scope: [ 'meeting:attend', 'meeting:speak' ] identity_status: authorized_agentverifyBundle is async — every cryptographic operation goes through crypto.subtle (in
the browser) or Node’s native crypto where available.
JSON transport — sending a bundle over the wire
Section titled “JSON transport — sending a bundle over the wire”A proof bundle is JSON on the wire. Convert byte arrays to base64 before transport, then decode
them back to Uint8Array before calling verifyBundle. The bundle produced by the TypeScript
SDK verifies in Go, Python, Rust, and C/C++ (and vice versa).
import express from "express";import { verifyBundle, type DelegationCert, type HybridPublicKey, type HybridSignature, type ProofBundle,} from "@identities-ai/ratify-protocol";
const b64 = (value: Uint8Array) => Buffer.from(value).toString("base64");const bytes = (value: string) => new Uint8Array(Buffer.from(value, "base64"));
const pubToJson = (key: HybridPublicKey) => ({ ed25519: b64(key.ed25519), ml_dsa_65: b64(key.ml_dsa_65),});
const sigToJson = (sig: HybridSignature) => ({ ed25519: b64(sig.ed25519), ml_dsa_65: b64(sig.ml_dsa_65),});
export function proofBundleToJson(bundle: ProofBundle): string { return JSON.stringify({ ...bundle, agent_pub_key: pubToJson(bundle.agent_pub_key), challenge: b64(bundle.challenge), challenge_sig: sigToJson(bundle.challenge_sig), session_context: bundle.session_context ? b64(bundle.session_context) : "", stream_id: bundle.stream_id ? b64(bundle.stream_id) : "", delegations: bundle.delegations.map((cert) => ({ ...cert, issuer_pub_key: pubToJson(cert.issuer_pub_key), subject_pub_key: pubToJson(cert.subject_pub_key), signature: sigToJson(cert.signature), })), });}
export function proofBundleFromJson(json: string): ProofBundle { const raw = JSON.parse(json); const decodePub = (key: any): HybridPublicKey => ({ ed25519: bytes(key.ed25519), ml_dsa_65: bytes(key.ml_dsa_65), }); const decodeSig = (sig: any): HybridSignature => ({ ed25519: bytes(sig.ed25519), ml_dsa_65: bytes(sig.ml_dsa_65), }); return { ...raw, agent_pub_key: decodePub(raw.agent_pub_key), challenge: bytes(raw.challenge), challenge_sig: decodeSig(raw.challenge_sig), session_context: raw.session_context ? bytes(raw.session_context) : undefined, stream_id: raw.stream_id ? bytes(raw.stream_id) : undefined, delegations: raw.delegations.map((cert: any): DelegationCert => ({ ...cert, issuer_pub_key: decodePub(cert.issuer_pub_key), subject_pub_key: decodePub(cert.subject_pub_key), signature: decodeSig(cert.signature), })), };}
// Agent side: serialize for transport.const bundleJson = proofBundleToJson(bundle);const proofHeader = Buffer.from(bundleJson, "utf8").toString("base64");// Send proofHeader as X-Ratify-Proof, a WebSocket message, a queue field, etc.
const app = express();
app.post("/tool/execute", async (req, res) => { const rawProof = req.headers["x-ratify-proof"]; if (!rawProof) return res.status(401).json({ error: "missing proof" });
let bundle: ProofBundle; try { bundle = proofBundleFromJson( Buffer.from(rawProof as string, "base64").toString("utf8") ); } catch { return res.status(400).json({ error: "malformed proof" }); }
const result = await verifyBundle(bundle, { required_scope: "execute:tool", });
if (!result.valid) { return res.status(403).json({ error: result.identity_status, reason: result.error_reason, }); }
// Proceed — agent is authorized res.json({ ok: true, agent_id: result.agent_id });});Standard convention: agents PUT the bundle JSON in the X-Ratify-Proof header
(base64-encoded). See the API Gateway guide for the full
middleware pattern.
Browser usage
Section titled “Browser usage”The same SDK works in browsers. No bundler-specific configuration required for Vite, Next.js,
SvelteKit, or Astro. The cryptographic primitives use crypto.subtle where available and fall
back to pure-JS Noble implementations elsewhere.
import { proofBundleFromJson } from "./ratify-wire";import { verifyBundle } from "@identities-ai/ratify-protocol";
// Bundle arrives from your server as the same base64-wrapped JSON string.const bundleJson = atob(proofHeaderFromServer);const bundle = proofBundleFromJson(bundleJson);
const result = await verifyBundle(bundle, { required_scope: "files:read",});Approximate bundle size for the verifier path: ~50 KB minified + gzipped (most of which is the ML-DSA-65 verifier — Ed25519 alone would be much smaller).
Verifier-side: branching on identity_status
Section titled “Verifier-side: branching on identity_status”verifyBundle always resolves with a VerifyResult. Inspect result.valid first; if it’s
false, the specific failure mode is in result.identity_status. The type is a string-literal
union — your editor will autocomplete the full set:
import type { IdentityStatus } from "@identities-ai/ratify-protocol";
switch (result.identity_status as IdentityStatus) { case "authorized_agent": // ✓ All checks passed. result.granted_scope is the intersection of // every cert's scope (effective scope across the chain). break;
case "expired": // ✗ At least one cert in the chain is past its expires_at, OR not-yet-valid. break;
case "revoked": // ✗ A cert ID in the chain matched a revoked entry returned by your // revocation provider (or legacy is_revoked closure). break;
case "scope_denied": // ✗ required_scope was not in the chain's effective (intersected) scope, // OR an advanced PolicyProvider rejected the bundle. break;
case "constraint_denied": // ✗ A first-class constraint (geo/time/amount/rate) evaluated to // "outside the allowed range". break;
case "constraint_unverifiable": // ✗ A constraint required input the caller didn't provide // (e.g. cert has geo_circle but context.current_lat was undefined). break;
case "constraint_unknown": // ✗ A cert declared a constraint type this SDK build doesn't recognize. // Fail-closed by design. break;
case "delegation_not_authorized": // ✗ An intermediate cert in the chain sub-delegated, but its parent // never granted "identity:delegate". The sub-delegation gate. break;
case "invalid": // ✗ Catch-all for structural / cryptographic failures. // result.error_reason carries a stable machine-readable prefix // (e.g. "stale_challenge: challenge is 31 seconds old (max 300)"). break;
default: // The compiler will flag this if the protocol adds new statuses. const _exhaustive: never = result.identity_status;}The verifier is fail-closed by default — any error path returns valid: false. The
machine-readable failure codes inside error_reason (e.g. stale_challenge, bad_signature,
broken_chain, key_mismatch) are stable; route audit pipelines on those without parsing
prose.
Constraints (geo / time / amount / rate / speed)
Section titled “Constraints (geo / time / amount / rate / speed)”When a delegation declares constraints, supply the runtime context:
import type { VerifierContext } from "@identities-ai/ratify-protocol";
const ctx: VerifierContext = { current_lat: 37.7749, current_lon: -122.4194,};
const result = await verifyBundle(bundle, { required_scope: "drone:deliver", context: ctx,});In TypeScript, the has_* flags from the Go API are inferred from field presence — leaving
current_lat and current_lon undefined is equivalent to “no location supplied” and any
geo_circle / geo_polygon / geo_bbox constraint on the cert will return
constraint_unverifiable.
The full constraint vocabulary (geo_circle, geo_polygon, geo_bbox, time_window,
max_speed_mps, max_amount, max_rate) and the VerifierContext field requirements are
documented in Constraints.
Sub-delegation
Section titled “Sub-delegation”Agent-to-agent delegation uses the same primitive. Alice grants Agent A meeting:attend plus
the identity:delegate privilege; A then issues a sub-delegation to Agent B. Without
identity:delegate on A’s grant from Alice, A’s sub-delegation will be rejected with
delegation_not_authorized.
import { SCOPE_IDENTITY_DELEGATE } from "@identities-ai/ratify-protocol";
// Alice → A: meeting:attend + identity:delegateconst aliceToA: DelegationCert = { cert_id: "cert-alice-to-a", version: PROTOCOL_VERSION, issuer_id: alice.id, issuer_pub_key: alice.public_key, subject_id: agentA.id, subject_pub_key: agentA.public_key, scope: [SCOPE_MEETING_ATTEND, SCOPE_IDENTITY_DELEGATE], issued_at: now, expires_at: now + 86400, signature: { ed25519: new Uint8Array(0), ml_dsa_65: new Uint8Array(0) },};await issueDelegation(aliceToA, alicePriv);
// A → B: a subset of A's grantconst aToB: DelegationCert = { cert_id: "cert-a-to-b", version: PROTOCOL_VERSION, issuer_id: agentA.id, issuer_pub_key: agentA.public_key, subject_id: agentB.id, subject_pub_key: agentB.public_key, scope: [SCOPE_MEETING_ATTEND], issued_at: now, expires_at: now + 3600, signature: { ed25519: new Uint8Array(0), ml_dsa_65: new Uint8Array(0) },};await issueDelegation(aToB, agentAPriv);
// Chain order: [leaf, ..., root]. A→B is the leaf, Alice→A is the root.const bundle: ProofBundle = { agent_id: agentB.id, agent_pub_key: agentB.public_key, delegations: [aToB, aliceToA], challenge, challenge_at: challengeAt, challenge_sig: agentBSig,};
const result = await verifyBundle(bundle, { required_scope: SCOPE_MEETING_ATTEND,});The effective granted_scope is the lex-sorted intersection of every cert in the chain.
identity:delegate doesn’t propagate to B because A didn’t grant it onward — that’s the
intentional behavior.
Provider hooks (SPEC §17)
Section titled “Provider hooks (SPEC §17)”Every hook from the §17 surface is exported as an interface:
import type { RevocationProvider, PolicyProvider, AuditProvider, ConstraintEvaluator, AnchorResolver, PolicyVerdict,} from "@identities-ai/ratify-protocol";
const result = await verifyBundle(bundle, { required_scope: "meeting:attend", revocation: myRevocationProvider, // §17.1 policy: myPolicyProvider, // §17.2 audit: myAuditProvider, // §17.3 constraint_evaluators: myExtensionRegistry, // §17.7 anchor_resolver: myAnchorResolver, // §17.8 policy_verdict: cachedVerdict, // §17.6 policy_secret: policySecret, context: ctx,});Each hook has fail-closed semantics — a RevocationProvider that resolves to an error fails
the bundle with identity_status: "invalid" + error_reason: "revocation_error: ...", not a
silent “unknown means allow.” See
Provider architecture for the full reference.
Type-safe scope strings
Section titled “Type-safe scope strings”The SDK exports every canonical scope as a constant. Use the constants in your code so the compiler catches typos before they reach the verifier:
import { SCOPE_MEETING_ATTEND, SCOPE_MEETING_SPEAK, SCOPE_PAYMENTS_AUTHORIZE, SCOPE_IDENTITY_DELEGATE,} from "@identities-ai/ratify-protocol";
// Compiler-safeconst scopes = [SCOPE_MEETING_ATTEND, SCOPE_PAYMENTS_AUTHORIZE];
// Compiler can't help you here — typo "meting:attend" would only surface at verify timeconst scopesUnsafe = ["meeting:attend", "payments:athorize"];The full canonical 54-scope vocabulary is documented at Scopes and
exposed in src/scope.ts.
Running the full test suite
Section titled “Running the full test suite”cd ratify-protocol/sdks/typescriptnpm test# → 105 tests pass (63 conformance fixtures + cross-SDK vectors + levers + providers)To run just the 63 canonical conformance fixtures:
npm run test:conformance# → 63/63 fixtures passIf any conformance fixtures fail, the bytes have drifted from the reference — file a bug.
Where to next
Section titled “Where to next”- Protocol concepts: Delegate → Present → Verify — every primitive in depth
- Constraints — full constraint vocabulary +
VerifierContextreference - Provider architecture — the §17 hooks plus the provider levers (
VerificationReceipt,PolicyVerdict) - Integration guides — surface-specific patterns