Skip to content

TypeScript SDK

The TypeScript SDK is byte-for-byte interoperable with Go, Python, and Rust. 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.7. There are no preview / experimental APIs in the snippets below.

npm publish for @identities-ai/ratify-protocol is queued behind org approval. For now, install from source:

Terminal window
git clone https://github.com/identities-ai/ratify-protocol
cd ratify-protocol/sdks/typescript
npm install
npm run build # → dist/
# In your project
npm install ../ratify-protocol/sdks/typescript

Once @identities-ai is approved on npm, the canonical install will be:

Terminal window
npm install @identities-ai/[email protected]
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_agent

verifyBundle is async — every cryptographic operation goes through crypto.subtle (in the browser) or Node’s native crypto where available.

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 { verifyBundle } from "@identities-ai/ratify-protocol";
// Bundle arrives from your server as JSON
const result = await verifyBundle(parsedBundle, {
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.

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:delegate
const 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 grant
const 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.

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.

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-safe
const scopes = [SCOPE_MEETING_ATTEND, SCOPE_PAYMENTS_AUTHORIZE];
// Compiler can't help you here — typo "meting:attend" would only surface at verify time
const scopesUnsafe = ["meeting:attend", "payments:athorize"];

The full canonical 53-scope vocabulary is documented at Scopes and exposed in src/scope.ts.

Terminal window
cd ratify-protocol/sdks/typescript
npm run test:conformance

You should see all 59 canonical wire-format fixtures plus the 10 alpha.7 cross-SDK byte-equivalence vectors pass. If any fail, the bytes have drifted from the reference and the SDK is not interoperable — file a bug.