For buyers.

The buyer side is where payment authority lives. Steelyard keeps the LLM agent away from rail credentials: the agent asks a local policy process for permission, and the wallet issues only a scoped payment mandate when the policy allows it.

Permission model

00Permission model
01

Agent request

intent + caller_token over local IPC

The LLM proposes a purchase; it does not receive rail credentials.

02

Policy decision

YAML rule + ledger reservation

The engine normalizes facts, checks caps, and records the policy snapshot.

03

Permission artifact

authorization_hash

The hash binds policy, facts, rail, amount, FX quote, and credential constraints.

04

Payment authority

scoped virtual card

Stripe Issuing keys stay inside the engine; the agent only sees a constrained credential.

Features

01Features
01

Wallet

Local-first wallet backed by an encrypted vault. Desktops use the OS keychain by default; headless environments can use password-derived encryption.

wallet.ts
import { Wallet } from "steelyard/buyer";

const wallet = await Wallet.create({
  project: true,
  password: "your-vault-password",
  card: { number: "4242424242424242", exp: "12/30", name: "Buyer" },
  billing: {
    email: "buyer@example.com",
    address: {
      line1: "1 Market St",
      city: "San Francisco",
      postal_code: "94105",
      country: "US",
    },
  },
  limits: { daily: { USD: 100 } },
  allowedMerchants: ["coffee.example"],
});

// Reopen later — vault is on disk, unlocked via OS keychain or password.
// await wallet.close();
// const reopened = await Wallet.open({
//   project: true,
//   password: "your-vault-password",
// });
02

Policy and permission engine

A separate buyer-side process gates proposed payments before any rail credential exists. The agent presents an intent over local IPC; the engine evaluates YAML policy, reserves budget, and either denies, asks for approval, or issues a scoped virtual card.

policy.yaml
# ~/.steelyard/policy.yaml
version: 2026-06-27
trusted_domains:
  coffee:
    - coffee.example
rules:
  - name: coffee-under-25
    do: allow
    rail: virtual_card
    when:
      merchant_domain_in: coffee
      amount_usd: { max: 25 }
      type: one_time
    limits:
      per_day_usd: 100
      per_day_count: 8

  - name: ask-user-above-25
    do: require_approval
    rail: virtual_card
    when:
      merchant_domain_in: coffee
      amount_usd: { min: 25, max: 100 }
    approval:
      who: user
      channel: webhook
      expires_in: 5m

  - name: deny-all
    do: deny
03

Scoped credential brokering

The LLM never holds Stripe keys. After an allow decision, the engine asks the card rail for a one-use credential bound to the authorization hash, amount, expiry, and rail constraints. A hijacked agent can spend only what that credential permits.

policy-engine.ts
import { PolicyEngine, EcbFxQuoteService } from "steelyard/policy";
import { virtualCardRail, WebhookEventBus } from "steelyard/policy-rail-card";

const webhookBus = new WebhookEventBus();
const engine = new PolicyEngine({
  dataDir: ".steelyard/policy",
  policyPath: ".steelyard/policy.yaml",
  socketPath: ".steelyard/policy/policy.sock",
  fx: new EcbFxQuoteService(),
  clock: { now: () => new Date() },
  rails: [
    virtualCardRail({
      stripe,
      cardholderId: process.env.STRIPE_ISSUING_CARDHOLDER!,
      env: "production",
      webhookBus,
    }),
  ],
});

await engine.start();
// Agent receives caller_token + policy.sock path, not Stripe credentials.
04

Unified client

Steelyard.connect(url) figures out the merchant protocol and returns one Merchant API for reads. wallet.purchase() runs checkout on ACP and UCP merchants; MCP remains read-only.

buy.ts
import { Wallet } from "steelyard/buyer";
import { Steelyard } from "steelyard/buyer/client";
import type { PurchaseIntent } from "steelyard/core";

const wallet = await Wallet.open({ project: true });
const merchant = await Steelyard.connect(
  "https://coffee.example/.well-known/ucp",
);
if ("error" in merchant) throw new Error(merchant.error);
if (!merchant.supports("checkout")) throw new Error("no checkout advertised");

const intent: PurchaseIntent = {
  merchant: { domain: "coffee.example", transport_url: merchant.url, protocol: merchant.protocol },
  offer: { id: "cappuccino", title: "Cappuccino", categories: ["coffee"] },
  amount: 500,
  currency: "USD",
  intent_id: "order-001",
};

const receipt = await wallet.purchase(intent, {
  merchant,
  idempotencyKey: "order-001",
});

console.log(receipt.order_id, receipt.status, receipt.charged_amount);
05

x402 paid fetch

For paid HTTP resources, add x402Payments to the Wallet and call x402Fetch. The first request receives PAYMENT-REQUIRED, the wallet evaluates policy, then the signer creates PAYMENT-SIGNATURE for the retry.

paid-fetch.ts
import { Wallet, x402Fetch, x402Payments } from "steelyard";

const wallet = await Wallet.open({ project: true });

await wallet.addInstrument(x402Payments({
  signer,
  networks: ["eip155:84532"],
  assets: ["USDC"],
  schemes: ["exact"],
}));

const fetchPaid = x402Fetch(wallet, {
  maxAmount: { amount: "0.10", currency: "USDC" },
});

const response = await fetchPaid("https://api.example.com/paid-weather");
console.log(response.x402?.receipt.transaction);
06

Catalog browsing without paying

Inspect offers before authorising any payment. merchant.getOffer(id) fetches one offer in full; merchant.search(query) lists matches. Both are read-only and require no key.

browse.ts
import { Steelyard } from "steelyard/buyer/client";

const merchant = await Steelyard.connect(
  "https://coffee.example/.well-known/ucp",
);
if ("error" in merchant) throw new Error(merchant.error);

// search returns matching offers, or { error } on protocol failure.
const matches = await merchant.search("espresso");
if (!("error" in matches)) {
  for (const offer of matches) {
    console.log(offer.id, offer.title, offer.pricing[0]);
  }
}

// getOffer fetches a single offer (with description, attributes, etc.).
const cappuccino = await merchant.getOffer("cappuccino");
if (!("error" in cappuccino)) {
  console.log(cappuccino.description, cappuccino.availability);
}
07

Idempotency-keyed payments

Every wallet.purchase() call accepts an idempotencyKey. Safe to retry a network failure: same key + same merchant returns the original receipt instead of charging twice.

retry.ts
import { Wallet } from "steelyard/buyer";
import { Steelyard } from "steelyard/buyer/client";
import type { PurchaseIntent } from "steelyard/core";

const wallet = await Wallet.open({ project: true });
const merchant = await Steelyard.connect(
  "https://coffee.example/.well-known/ucp",
);
if ("error" in merchant) throw new Error(merchant.error);

const idempotencyKey = "order-2026-06-27-001";

async function pay(intent: PurchaseIntent) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      return await wallet.purchase(intent, { merchant, idempotencyKey });
    } catch (err) {
      if (attempt === 3) throw err;
      await new Promise((r) => setTimeout(r, 250 * attempt));
    }
  }
}
08

Signed UCP requests (HMS)

Vault-generated ES256/ES384 keys sign UCP checkout requests via RFC 9421 HTTP Message Signatures. Create the key once on the wallet; advertise the public key through the buyer profile; pass the kid to Steelyard.connect via ucpAuth.

signed-buy.ts
import { Wallet } from "steelyard/buyer";
import { Steelyard } from "steelyard/buyer/client";

const wallet = await Wallet.open({ project: true });

// Create (or reuse) a UCP signing key. The private key never leaves the vault.
const { kid } = await wallet.createUcpSigningKey({ algorithm: "ES256" });
const publicKey = await wallet.exportUcpSigningPublicKey();

// The merchant fetches this public key from your buyer profile (which you
// also serve over /.well-known/ucp, alongside the kid).
const merchant = await Steelyard.connect(
  "https://coffee.example/.well-known/ucp",
  {
    ucpAuth: {
      preferred: "hms",
      signing: {
        kid,
        algorithm: "ES256",
        profileUrl: "https://buyer.example/.well-known/ucp",
      },
    },
  },
);
09

AP2 mandates

AP2 (SD-JWT+KB) cryptographic consent for cart and payment. The wallet keeps the UCP signing key in the encrypted vault; the buyer profile advertises AP2 alongside HMS so capability negotiation picks it up.

ap2.ts
import { Wallet } from "steelyard/buyer";
import { createUcpBuyerProfileHandler } from "steelyard/buyer/client";

const wallet = await Wallet.open({ project: true });

// AP2 uses the same vault-backed UCP signing key as HMS.
if (!(await wallet.hasUcpSigningKey())) {
  await wallet.createUcpSigningKey({ algorithm: "ES256" });
}
const ucpPublicKey = await wallet.exportUcpSigningPublicKey();

// Mount this on your buyer-side /.well-known/ucp so merchants can discover
// your AP2 capability and verify the mandate.
const profile = createUcpBuyerProfileHandler({
  signingKeys: [ucpPublicKey],
  ap2: { enabled: true },
});
10

Receipt history and reconciliation

Every wallet.purchase() returns a Receipt with the merchant's reference, the captured amount, and the order id. The wallet keeps a local ledger; listReceipts() and listSpend() let you reconcile against external systems.

receipts.ts
import { Wallet } from "steelyard/buyer";

const wallet = await Wallet.open({ project: true });

const receipt = await wallet.purchase(intent, { merchant, idempotencyKey });
console.log(
  receipt.order_id,
  receipt.status,           // "completed" | "failed" | ...
  receipt.charged_amount,
  receipt.charged_currency,
  receipt.reference,        // { acp?: {...}, ucp?: {...} } per protocol
);

// Local ledger queries — useful for monthly reconciliation.
const lastMonth = await wallet.listReceipts({
  since: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
});
const spend = await wallet.listSpend({
  since: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
});
11

Custom mandate issuers

Stripe SPT and the reference mandate ship in-tree as agent-native wallet instruments. A custom PaymentMandateIssuer declares an instrumentType, issues a scoped PaymentMandate, and is added to the Wallet as an instrument.

my-rail-issuer.ts
import { Wallet } from "steelyard/buyer";
import type { PaymentMandateIssuer, PaymentMandateRequest, PaymentMandate } from "steelyard/core";

const myRailIssuer: PaymentMandateIssuer = {
  instrumentType: "my-rail-token",

  async issueMandate(mandate: PaymentMandateRequest): Promise<PaymentMandate> {
    // Call your rail, scope the token to this mandate, return the mandate.
    const token = await myRail.createScopedToken({
      amount: mandate.payment.amount,
      currency: mandate.payment.currency,
      checkout_id: mandate.payment.checkout_id,
      merchant_id: mandate.merchant_id,
      ttl_seconds: 120,
    });
    return {
      id: token.id,
      max_amount: mandate.payment.amount,
      currency: mandate.payment.currency,
      expires_at: token.expires_at,
      scope_proof: { type: "my_rail_scope", token: token.id },
    };
  },
};

const wallet = await Wallet.open({ project: true });
await wallet.addInstrument({
  mode: "agent-native",
  type: "my-rail-token",
  label: "My rail",
  issuer: myRailIssuer,
});

Packages

02Packages
steelyard/buyer

Wallet, encrypted vault, receipts, and the explore-and-buy client.

steelyard/policy

Buyer-side policy engine: YAML rules, authorization hash, reservation ledger, approvals, local IPC, and audit log.

steelyard/policy-rail-card

Stripe Issuing rail adapter that mints constrained virtual cards and normalizes settlement webhooks.

steelyard/buyer/client

Steelyard.connect() and the buyer-side UCP profile handler.

steelyard/stripe/buyer

stripeSpt — agent-native Stripe Shared Payment Token instrument for the wallet.

steelyard/x402

x402Payments and x402Fetch for Wallet-governed paid HTTP requests.

steelyard/core

Manifest schema, PurchaseIntent, PaymentInstrument, PaymentMandate, Receipt, Decision.

Full reference: buyer and policy package READMEs ↗