paybondpaybond
Sign in

Agent runtime tutorial (TypeScript)

Create an intent, guard an OpenAI Agents tool handler with Paybond, and submit signed evidence from TypeScript.

Agent runtime tutorial (TypeScript)

Paybond does not ship a first-party TypeScript OpenAI Agents helper today. The supported TypeScript path is a small application-owned wrapper around your tool handler using PaybondCapabilityBinding and harbor.verifyCapability(...).

That wrapper is deliberately small: the agent runtime stays yours, while Paybond still controls tenant binding, capability checks, and evidence submission.

What you will build

One guarded tool flow:

  1. Open a tenant-bound Paybond session.
  2. Create an intent for travel.book_hotel.
  3. Read the returned capability_token.
  4. Wrap the tool handler with a capability check.
  5. Execute the tool only if Harbor allows it.
  6. Submit signed evidence and inspect the resulting Harbor state.

Install

npm install @paybond/kit

For a complete example application, see the reference implementation linked at the end of this page.

Required environment

export PAYBOND_GATEWAY_URL="https://gateway.example.com"
export PAYBOND_HARBOR_URL="https://harbor.example.com"
export PAYBOND_API_KEY="paybond_sk_..."
export PAYBOND_PRINCIPAL_DID="did:web:example.com#principal"
export PAYBOND_PRINCIPAL_SEED_HEX="..."
export PAYBOND_PAYEE_DID="did:web:example.com#hotel-booker"
export PAYBOND_PAYEE_SEED_HEX="..."
export PAYBOND_SETTLEMENT_RAIL="x402_usdc_base" # optional
export PAYBOND_X402_PAYMENT_SIGNATURE="demo-signature" # optional

End-to-end flow

import { Buffer } from "node:buffer";
import { Paybond, PaybondCapabilityBinding } from "@paybond/kit";

function seed32FromHex(envName: string): Uint8Array {
  const value = process.env[envName];
  if (!value) {
    throw new Error(`missing env ${envName}`);
  }
  const raw = Buffer.from(value.replace(/^0x/i, ""), "hex");
  if (raw.length !== 32) {
    throw new Error(`${envName} must decode to exactly 32 bytes`);
  }
  return new Uint8Array(raw);
}

type Reservation = Readonly<{
  hotel: string;
  city: string;
  status: "confirmed";
  price_cents: number;
  confirmation: string;
}>;

function guardToolWithPaybond<TArgs extends unknown[], TResult>(init: {
  binding: PaybondCapabilityBinding;
  operation: string;
  requestedSpendCents: number;
  execute: (...args: TArgs) => Promise<TResult>;
}): (...args: TArgs) => Promise<TResult> {
  return async (...args: TArgs): Promise<TResult> => {
    const verified = await init.binding.harbor.verifyCapability({
      intentId: init.binding.intentId,
      token: init.binding.capabilityToken,
      operation: init.operation,
      requestedSpendCents: init.requestedSpendCents,
    });
    if (!verified.allow) {
      throw new Error(
        `verify denied: ${verified.code ?? "deny"} ${verified.message ?? ""}`.trim(),
      );
    }
    return init.execute(...args);
  };
}

async function bookHotel(city: string, nightlyBudgetCents: number): Promise<Reservation> {
  return {
    hotel: "Harbor House",
    city,
    status: "confirmed",
    price_cents: nightlyBudgetCents,
    confirmation: "HB-2049",
  };
}

const paybond = await Paybond.open({
  gatewayBaseUrl: process.env.PAYBOND_GATEWAY_URL!,
  apiKey: process.env.PAYBOND_API_KEY!,
  harborBaseUrl: process.env.PAYBOND_HARBOR_URL!,
});

try {
  const intentId = crypto.randomUUID();
  const created = await paybond.intents.create({
    principalDid: process.env.PAYBOND_PRINCIPAL_DID!,
    principalSigningSeed: seed32FromHex("PAYBOND_PRINCIPAL_SEED_HEX"),
    payeeDid: process.env.PAYBOND_PAYEE_DID!,
    budget: { currency: "usd", max_spend_usd: 200 },
    predicate: {
      version: 1,
      root: {
        op: "and",
        clauses: [
          {
            op: "completion",
            path: ["reservation", "status"],
            value: "confirmed",
          },
          {
            op: "budget_cap",
            path: ["reservation", "price_cents"],
          },
        ],
      },
    },
    currency: "usd",
    amountCents: 20_000,
    evidenceSchema: { type: "object", properties: { reservation: { type: "object" } } },
    deadlineRfc3339: "2030-12-31T23:59:59Z",
    allowedTools: ["travel.book_hotel"],
    intentId,
    idempotencyKey: `intent:${intentId}`,
  });

  const capabilityToken = String(created.capability_token ?? "");
  if (!capabilityToken) {
    throw new Error("intent created without capability_token; ensure the intent is funded");
  }

  const binding = new PaybondCapabilityBinding(
    paybond.harbor,
    intentId,
    capabilityToken,
  );

  const bookHotelTool = {
    name: "travel.book_hotel",
    description: "Reserve one hotel room inside the intent budget.",
    execute: guardToolWithPaybond({
      binding,
      operation: "travel.book_hotel",
      requestedSpendCents: 18_700,
      execute: bookHotel,
    }),
  };

  const reservation = await bookHotelTool.execute("Lisbon", 18_700);
  const submitted = await paybond.intents.submitEvidence({
    intentId,
    payeeDid: process.env.PAYBOND_PAYEE_DID!,
    payeeSigningSeed: seed32FromHex("PAYBOND_PAYEE_SEED_HEX"),
    payload: { reservation },
    artifactsBlake3Hex: [],
    idempotencyKey: `evidence:${intentId}`,
  });

  console.log(
    JSON.stringify(
      {
        intentState: created.state,
        tool: bookHotelTool.name,
        settlementState: submitted.state,
        predicatePassed: submitted.predicatePassed,
      },
      null,
      2,
    ),
  );
} finally {
  await paybond.aclose();
}

Where the OpenAI Agents SDK fits

In a real OpenAI Agents app, keep the same wrapper body and place it around the tool handler you register with the SDK. The important invariant is not the hook name; it is that travel.book_hotel is verified against Harbor before the tool executes.

Because the wrapper is app-owned:

  • your tool name must match allowedTools exactly
  • the binding must stay scoped to one (tenant, intent, capability_token)
  • evidence submission should happen after the guarded tool returns its result

Stablecoin rail variant

Set PAYBOND_SETTLEMENT_RAIL="x402_usdc_base" to request the Base / USDC rail. The example keeps the commercial amount in USD (budget.currency: "usd", amountCents, max_spend_usd) while Harbor handles x402 funding and settlement in USDC on Base.

If create does not return capability_token, the example calls paybond.intents.fund, prints the paymentRequired challenge, and retries after you provide PAYBOND_X402_PAYMENT_SIGNATURE.

Expected Harbor outcome

  • create may return capability_token immediately, or the example may obtain it through paybond.intents.fund on x402_usdc_base.
  • submitEvidence returns a state plus optional predicatePassed.
  • Settlement confirmation remains an operator-tier action in the current Kit surface. See Harbor API.

Common failure cases

  • Missing capability_token: the intent is not funded yet.
  • Missing PAYBOND_X402_PAYMENT_SIGNATURE: Harbor returned an x402 challenge and is waiting for a signed retry.
  • Verify denial: the wrapper used the wrong operation, requested spend exceeded the capability, or the capability token is stale.
  • Tenant mismatch: treat as a critical tenant-isolation error; do not retry with a different tenant.
  • Evidence rejection: the payee seed, DID, or payload does not match what the intent expects.

Reference implementation