SDK

@tollrelay/sdk signs every outbound request and checks the mandate as a courtesy before it leaves.

npm install @tollrelay/sdk

The signer

The SDK never holds a private key. signedFetch, the pass refresh and the AP2 helpers take an AgentSigner and call its sign(). The key is made on the device the agent runs on, and only its public half is ever sent. fingerprint is the key's JWK thumbprint (RFC 7638), the value the signature's keyid and the pass's cnf.jkt carry.

interface AgentSigner {
  readonly fingerprint: string;
  readonly alg: "ed25519" | "ecdsa-p256-sha256";
  publicJwk(): JsonWebKey;
  sign(bytes: Uint8Array): Promise<Uint8Array>;
}
The three key tiers
TierWhere the key isSigner
fileA key file on the device, at mode 0600, or a private JWK from the runtime's own secret store.fileSigner, in the SDK. trlay pass create makes this key.
serviceA cloud key service that never releases the key. Ed25519.serviceSigner, over an adapter a key service package implements.
hardwareThe device's secure hardware, proven by attestation.A platform package, later.

trlay pass create makes an Ed25519 key on this machine, keeps the private half at ~/.trlay/keys/<mid>.json at mode 0600 and registers only the public half. fileSigner imports it once as a non-extractable key and refuses a key file other users can read. generateAgentKey() makes one in code.

import { fileSigner } from "@tollrelay/sdk";

const signer = await fileSigner({ path: `${process.env.HOME}/.trlay/keys/${mid}.json` });

signedFetch

A fetch wrapper. It signs the request per RFC 9421 through the agent's signer, checks the mandate as a courtesy, signs a second time for the edge, and shows the pass to a seller that asks for it.

import { agentPass, fileSigner, signedFetch } from "@tollrelay/sdk";

const signer = await fileSigner({ path: keyPath });

const call = signedFetch({
  signer,
  pass: agentPass({ mid, org, signer }),
  mandate: { usd_per_day: "10.000", sellers_allow: ["seller.example.com"] },
});

const response = await call("https://seller.example.com/quote", {
  method: "POST",
  body: JSON.stringify({ sku: "abc" }),
});

Framework packages

Each framework package puts signedFetch inside a framework's own hook, so the pass is one field where the framework already takes a fetch or a tool. Each takes signedFetch's options: the signer, the pass and the mandate.

The framework packages
FrameworkPackage
Vercel AI SDK
ai ^7.0.126
@tollrelay/vercel-ai
tollrelayFetch, typed as the AI SDK's FetchFunction, for any option that takes a custom fetch.
LangChain JS
@langchain/core ^1.2.17
@tollrelay/langchain
tollrelayTool, a DynamicStructuredTool whose func receives the signed fetch.
OpenAI Agents SDK
@openai/agents ^0.18.0
@tollrelay/openai-agents
tollrelayTool, a FunctionTool whose execute receives the signed fetch.
Cloudflare Agents SDK
agents ^0.28.1
ai ^7.0.126
@tollrelay/cloudflare-agents
tollrelayFetch, for a call an Agent subclass makes to a seller or the fetch inside a tool's execute.
npm install @tollrelay/sdk @tollrelay/openai-agents @openai/agents
import { tollrelayTool } from "@tollrelay/openai-agents";
import { agentPass, fileSigner } from "@tollrelay/sdk";
import { z } from "zod";

const signer = await fileSigner({ path: keyPath });

const quote = tollrelayTool({
  name: "quote",
  description: "Get a quote from the seller.",
  parameters: z.object({ sku: z.string() }),
  identity: {
    signer,
    pass: agentPass({ mid, org, signer }),
    mandate: { usd_per_day: "10.000", sellers_allow: ["seller.example.com"] },
  },
  execute: async (input, call) => {
    const response = await call("https://seller.example.com/quote", {
      method: "POST",
      body: JSON.stringify({ sku: input.sku }),
    });
    return response.json();
  },
});

quote is an ordinary FunctionTool: hand it to an Agent's tools array as usual. The LangChain package takes the same identity field, with schema and func in place of parameters and execute.

AI Gateway budgets

An AI Gateway budget stops model requests through the gateway once it is reached. It does not cover what the agent buys from sellers. The mandate does, at every seller that runs the check: the check refuses a purchase over it. A model named by a string goes through the gateway when no other provider is set, so the model call stays as it is and the tool's call to the seller goes through tollrelayFetch. stopWhen gives the model a second step, to answer from the quote.

import { generateText, stepCountIs, tool } from "ai";
import { tollrelayFetch } from "@tollrelay/vercel-ai";
import { agentPass, fileSigner } from "@tollrelay/sdk";
import { z } from "zod";

const signer = await fileSigner({ path: keyPath });
const call = tollrelayFetch({
  signer,
  pass: agentPass({ mid, org, signer }),
  mandate: { usd_per_day: "10.000", sellers_allow: ["seller.example.com"] },
});

const { text } = await generateText({
  model: "anthropic/claude-sonnet-5.5",
  tools: {
    quote: tool({
      description: "Get a quote from the seller.",
      inputSchema: z.object({ sku: z.string() }),
      execute: async ({ sku }) => {
        const response = await call("https://seller.example.com/quote", {
          method: "POST",
          body: JSON.stringify({ sku }),
        });
        return response.ok ? response.json() : { refused: response.status };
      },
    }),
  },
  stopWhen: stepCountIs(2),
  prompt: "Get a quote for sku abc.",
});

The pass, shown when asked

The first request to a host carries the two signatures and nothing else: no pass and no Trlay-Requester. A host that answers 401 with WWW-Authenticate: Bearer realm="tollrelay", as gate() does, or 402 with x402 requirements that name TollRelay, gets the request again once with both, and signedFetch remembers it for as long as the wrapper lives. showPass: "always" sends both on every request.

const call = signedFetch({ signer, pass, showPass: "always" });

The edge signature

Beside the trlay signature, every request carries a second signature under the label wba, made by the same key with the same keyid, in the Web Bot Auth profile: tag web-bot-auth, covering @authority, @path, signature-agent;key="wba" and content-digest when a body is sent, with created, expires 30 seconds later and a 16-byte nonce. The Signature-Agent header names the organisation's origin, wba="https://<handle>.agents.trlay.dev", taken from the pass's agent_origin. A site reads the wba member and ignores the trlay member; gate() and TollRelay read trlay only. The SDK adds both members to any Signature-Input and Signature the request already carries. A pass with no agent_origin, or with edge: false, sends no wba signature.

signRequest

The signing primitive under the wrapper, for a caller that sets its own headers.

import { signRequest } from "@tollrelay/sdk";

const headers = await signRequest({
  method: "POST",
  url: "https://seller.example.com/quote",
  body: JSON.stringify({ sku: "abc" }),
  signer,
});

What is signed

Covered components: @method, @authority, @path, @query, content-digest, then trlay-requester when the request carries one. Parameters: created, expires 30 seconds later, a 16-byte nonce, keyid (the key's JWK thumbprint, computed from the key), alg (ed25519, or ecdsa-p256-sha256 for a P-256 key) and tag trlay-agent. Digest sha-256 (RFC 9530), sent for an empty body too. Signature label trlay.

The pass names the agent's key in cnf.jkt, the same thumbprint as the signature's keyid. The check verifies the signature against that key, inside its window, and accepts each nonce once, so a copied pass without the key buys nothing.

Mint the pass

An agent mints its own pass with a request signed by its key, and no dashboard token. The pass lives 15 minutes; agentPass mints again at ten, through the signer, so a running agent never needs a person to hand it one.

POST https://pr-279-api.trlay.sh/identity/machines/<mid>/token
import { agentPass, fileSigner, signedFetch } from "@tollrelay/sdk";

const signer = await fileSigner({ path: keyPath });
const pass = agentPass({ mid, org, signer });

const call = signedFetch({ signer, pass });

Gate a route

gate() verifies the request itself before any call: the body against its Content-Digest, the pass's key against the agent's DID document, the signature and its window, the key's status bit when the pass names one, and the nonce as far as this process has seen it. It refuses with the check's own code: 401 signature_missing, signature_invalid, signature_replayed or token_invalid, 403 identity_revoked. The DID document and the status list are kept as long as their Cache-Control allows and never longer than five minutes, so a revocation reaches the gate within five minutes. A request that passes is relayed to TollRelay with your verifier key, with the pass and the signature, and verified again. Otherwise it answers with the pass and its receipt, or with the check's own problem, unchanged: 402 mandate_exceeded with its receipt, 401 signature_invalid or signature_replayed, 401 credential_missing with a WWW-Authenticate: Bearer challenge whose realm is tollrelay, which is how an agent's SDK learns to send its pass to you. You can add parameters to the challenge; the realm stays. If the check cannot be reached, or refuses your verifier key, it answers 500 internal and serves nothing, so the agent is never told its pass is at fault for your key. Behind a proxy that rewrites the host or the path, the signature fails.

import { gate, TollRelayClient, withGate } from "@tollrelay/sdk";

const check = gate({
  client: new TollRelayClient({ credential: env.TRLAY_VERIFIER_KEY }),
  seller: "seller.example.com",
  amountUsd: "0.020",
});

export default {
  fetch: withGate(check, (request, pass) =>
    Response.json({ quote: 42 }, { headers: { "x-trlay-receipt": pass.receipt } }),
  ),
};

In a Next.js App Router route file, withGate's answer is the route handler itself, because a route handler takes a Request and answers a Response. The gate reads a copy of the body, so the handler can still read it.

// app/quote/route.ts
import { gate, TollRelayClient, withGate } from "@tollrelay/sdk";

const check = gate({
  client: new TollRelayClient({ credential: process.env.TRLAY_VERIFIER_KEY! }),
  seller: "seller.example.com",
  amountUsd: "0.020",
});

export const POST = withGate(check, async (request, pass) => {
  const { sku } = await request.json();
  return Response.json({ sku, quote: 42 }, { headers: { "x-trlay-receipt": pass.receipt } });
});

Admit a known agent

admit() is for a site that sells nothing and wants to know which company's agent is asking. It reads the wba member, requires the Signature-Agent origin to end in .agents.trlay.dev (or the suffixes you pass), reads that origin's key directory under its cache headers, verifies the signature over your own host and inside its window, and reads the company's verification state from the trust list. It reads no pass, no limit and no spend, and never calls the check. A refusal carries one of no_signature, malformed, origin_not_allowed, directory_unavailable, unknown_key, invalid or expired.

import { admit } from "@tollrelay/sdk";

const answer = await admit(request);
if (!answer.ok) return new Response(null, { status: 403 });
// answer: { ok: true, handle, fingerprint, verified }

Who asked

A buyer can record who asked an agent to buy without TollRelay or the seller learning who. newRequesterKey() makes a person key, rk_ and 43 base64url characters. Keep it beside that employee in your own systems; it never leaves your machine. Pass it to signedFetch as requester and each request carries Trlay-Requester, a pseudonym made for that seller's host and covered by the request signature. Two sellers never see the same pseudonym for one person, and deleting the key erases the person. The check verifies the pair against the request signature, and the record keeps it as evidence for you, never part of the check's decision. A seller needs no code of its own: gate() forwards the header, and the x402 echo carries it as requester beside identity_jwt. resolveRequester() turns a pseudonym back into your own reference, on your machine, from the keys you hold.

import { newRequesterKey, resolveRequester, signedFetch } from "@tollrelay/sdk";

const key = newRequesterKey();

const call = signedFetch({ signer, pass, requester: key });

const who = await resolveRequester(
  { id: entry.requester.id, aud: entry.requester.aud },
  [{ ref: "ana@acme.example", key }],
);

The courtesy check

The mandate check the SDK runs before a request leaves is a courtesy: it spares the agent a call the seller would refuse. It is never the control. The seller's gate enforces the mandate, and the wallet enforces the spend.

The identity header

X-Trlay-Identity carries the pass beside the signature to a host that asks for it, until the credential travels in each rail's own slot.