Dexter
Dexter
Docs
Tabs

Verified against @dexterai/x402 5.3.1 · 2026-07-07

Tabs from a Node agent

By the end of this page you will have a headless Node agent that opens a Dexter tab through a spend grant, pays a seller across many calls (including a streamed response), and settles on close — with no browser and no passkey in its process.

A browser tab opens with a passkey: the user taps, a session key is authorized, and openTab() runs in the page (that path is Tabs in the browser). A Node agent has no passkey. It gets a tab a different way: the app proposes a capped, expiring grant, a human approves it once on a Dexter consent page, and the agent walks away with two artifacts it can use headlessly forever after. This page is that path end to end.

The two artifacts

The whole ceremony exists to hand your agent exactly two things:

  1. the session secret key — the ed25519 key the agent signs vouchers with, and
  2. the consented ApprovedSpendGrantParams — the exact cap, expiry, counterparty, and nonce the user approved.

Everything else (tabFromGrant) rebuilds from those two plus the chain. The session key is scoped: it can only spend up to the cap, only until expiry, only to the one seller. The wallet owner can revoke it at any time on their own surfaces.

You choose who generates the key. Custody mode (ii) — the agent generates its own keypair and sends only the public key into the grant — is the clean headless story, because the secret never leaves the agent's process. This page uses mode (ii). (Mode (i): the consent page generates the keypair and returns the secret to you. Same result, but the secret crosses the callback.)

Where vaultPda and swigAddress come from

A headless caller does not derive these from a browser session. Concretely:

  • vaultPda is not in the grant request. That is deliberate — an app-supplied vault address is a phishing surface, so the consent page resolves the user's vault from their own identity and reports it back to you in the callback. You pass that value to tabFromGrant.
  • swigAddress (the buyer's Swig state account) you do not need to supply. tabFromGrant reads it from the on-chain vault account when you omit it — one extra RPC read, never a guess. Pass it only if you already hold it and want to skip the read.

The wire shape of the consent callback — how params and vaultPda are delivered back to your agent — is a dexter.cash server contract, not part of the published packages. What the packages pin is the shape you must end up holding: ApprovedSpendGrantParams (below) and a base58 vaultPda string.

Install

npm install @dexterai/x402 @dexterai/vault @solana/web3.js tweetnacl

Step 1 — the app proposes the grant

The app builds a grant request describing what it wants: the seller it will pay (counterparty), a total cap in atomic USDC (6 decimals), and an expiry. In mode (ii) the agent generates its session keypair here and puts only the public key in the request. encodeSpendGrantRequest turns the blob into the ?req= value for the consent URL.

propose.ts
import nacl from 'tweetnacl';
import { PublicKey } from '@solana/web3.js';
import { requestSpendGrant, encodeSpendGrantRequest } from '@dexterai/vault/grant';
 
// The agent generates its own session keypair and keeps the secret.
// Persist sessionKeypair.secretKey somewhere the agent process can read later.
const sessionKeypair = nacl.sign.keyPair();
 
const request = requestSpendGrant({
  app: {
    name: 'Research Agent',
    domain: 'agent.example.com',
  },
  counterparty: 'SELLER_SETTLEMENT_ADDRESS', // base58; the seller you will pay
  capAtomic: '5000000', // 5 USDC total, 6 decimals
  expiresAtUnix: Math.floor(Date.now() / 1000) + 3600, // 1 hour
  sessionPubkey: new PublicKey(sessionKeypair.publicKey).toBase58(),
});
 
const req = encodeSpendGrantRequest(request);
const consentUrl = `https://dexter.cash/tabs/connect?req=${req}`;
// Send consentUrl to the human (print it, DM it, render a QR).

counterparty must be a real base58 pubkey — requestSpendGrant throws SpendGrantValidationError (bad_pubkey) if it is not. The user can only ever shorten what you propose (lower the cap, sooner expiry), never raise it.

Step 2 — the human approves (out of your process)

The human opens consentUrl, sees your app's name and the exact scope, taps their passkey once, and approves. On that page Dexter's sponsor lands the on-chain register_session_key transaction and pays the rent — your agent signs nothing here and holds no SOL. When it finishes, the consent page reports the outcome to your callback: the consented params and the user's vaultPda.

The shape your agent must end up holding:

ApprovedSpendGrantParams (from @dexterai/vault/grant)
interface ApprovedSpendGrantParams {
  counterparty: string;              // the seller, base58
  sessionPubkey: string;             // your session public key, base58
  maxAmountAtomic: string;           // final cap (user may have shortened it)
  expiresAtUnix: number;             // final expiry (user may have shortened it)
  nonce: number;
  maxRevolvingCapacityAtomic: string;
}

Pass these back verbatim. tabFromGrant rebuilds the byte-exact 188-byte registration message from them, and that message rides every voucher — any drift and the seller rejects your vouchers.

Step 3 — the agent opens the tab headlessly

With the session secret it generated and the params it got back, the agent calls tabFromGrant. No passkey, no browser. It validates the key against params.sessionPubkey before any I/O, reads the on-chain session to find where spending resumes, arms drain protection through the facilitator, and returns a live Tab. Any failure — key/params mismatch, dead session, exhausted cap, unarmed protection — throws. You never get an invalid tab.

open.ts
import { Connection } from '@solana/web3.js';
import { tabFromGrant } from '@dexterai/x402/tab';
import type { ApprovedSpendGrantParams } from '@dexterai/vault/grant';
 
// From the consent callback:
const params: ApprovedSpendGrantParams = /* ...from callback... */;
const vaultPda: string = /* ...from callback (base58)... */;
 
const tab = await tabFromGrant({
  sessionSecretKey: sessionKeypair.secretKey, // the 64-byte nacl secret from Step 1
  params,
  vaultPda,
  connection: new Connection('https://api.mainnet-beta.solana.com'),
  perUnitCapAtomic: '10000', // max 0.01 USDC on any single voucher
  // swigAddress omitted → read from the on-chain vault account.
  // facilitatorUrl omitted → https://x402.dexter.cash.
});

perUnitCapAtomic is required and is the blast radius of one request: every signed voucher is a bearer claim, so this caps how much a single call can charge against a misbehaving seller. Set it near your known per-call price.

sessionSecretKey accepts the 64-byte nacl secretKey you generated (a 32-byte ed25519 seed also works). tabFromGrant copies it internally; zero your buffer after.

Read the tab's state

state.ts
console.log(tab.counterparty); // the seller this tab pays, base58
console.log(tab.channelId);    // deterministic channel id
 
console.log(tab.state.spent);        // cumulative human USDC on this session
console.log(tab.state.remaining);    // headroom left under the cap
console.log(tab.state.expiresInSec); // seconds until the session expires

tab.state.spent is the session's lifetime odometer. For a grant-resumed tab it includes any amount the session already spent on chain before this process opened it, not just what this run has spent. If you display it as "what this run spent," you will overstate it.

Step 4 — pay across calls, including streaming

tab.stream(url) makes a paid streamed request and returns an async iterable of byte chunks. Voucher signing is internal: the seller demands a fresh session-signed voucher before each chunk, so you are paid up exactly to what you have received. The iterable throws — and stops — the moment the cap or expiry is hit or a signature is rejected. It never silently keeps streaming past a failure.

stream.ts
const decoder = new TextDecoder();
const streamed = await tab.stream('https://seller.example.com/v1/answer');
 
let text = '';
for await (const chunk of streamed) {
  text += decoder.decode(chunk, { stream: true });
}
text += decoder.decode(); // flush
console.log(text);

Call tab.stream() as many times as you need against the same tab. Each call amortizes onto the one open session; you settle once, at close.

Non-streaming sellers

For a seller that returns a whole response instead of a stream, sign the voucher yourself and attach it. signNextVoucher(incrementAtomic) advances the cumulative counter and signs, without sending anything; voucherToHeader encodes it into the X-Tab-Voucher header the seller middleware parses. The increment must cover the seller's quoted price for the call.

single.ts
import { voucherToHeader } from '@dexterai/x402/tab';
 
const voucher = await tab.signNextVoucher('2000'); // 0.002 USDC, atomic
const res = await fetch('https://seller.example.com/v1/answer', {
  headers: { 'X-Tab-Voucher': voucherToHeader(voucher) },
});

signNextVoucher enforces the same cap as stream() — it throws SessionScopeExceededError past the cap.

Step 5 — settle and close

close() posts the final cumulative voucher through the facilitator, which settles it on chain (USDC moves from the buyer's vault to the seller). The result carries the real settlement signature.

close.ts
import { SessionScopeExceededError, TabClosedError } from '@dexterai/x402/tab';
 
try {
  const receipt = await tab.close();
  console.log(receipt.settledAmount); // human USDC settled on chain
  console.log(receipt.settleTx);      // on-chain settlement signature
  console.log(receipt.sessionRevoked); // false for a grant tab — see below
} catch (err) {
  if (err instanceof SessionScopeExceededError) {
    console.error('cap or expiry hit:', err.reason);
  } else if (err instanceof TabClosedError) {
    console.error('already closed:', err.channelId);
  } else {
    throw err;
  }
}

Close on a grant tab is settle-only. It posts the final voucher and zeroes the in-memory key, but it does not revoke the session on chain — revocation is passkey-signed and your agent has no passkey. receipt.sessionRevoked is false and reports this honestly. The session PDA stays live under its cap and expiry until the wallet owner revokes it on their own surface or it expiry-sweeps. (A browser openTab tab revokes on close; this one cannot.)

One session key, one spend at a time

tabFromGrant reads the resume point from the chain once, at open. If two spenders hold the same session key concurrently, that read can go stale. The failure mode is safe — the seller and chain reject the out-of-order voucher (non_monotonic), they never double-spend — but the rejected call fails. If you drive one session key from multiple concurrent paths, serialize per (vault, counterparty): one live tab per seller, one spend in flight at a time.

Full loop

  1. App calls requestSpendGrant + encodeSpendGrantRequest, sends the consent URL to a human.
  2. Human approves once on the Dexter consent page; the sponsor registers the session on chain.
  3. Agent receives params + vaultPda from the callback.
  4. Agent calls tabFromGrant with its session secret, params, and vaultPda.
  5. Agent pays across calls with tab.stream() or tab.signNextVoucher().
  6. Agent calls tab.close() to settle. The session stays live under its cap until the owner revokes it or it expires.

What is proven here

The grant-proposal code (requestSpendGrantencodeSpendGrantRequest) was executed against the installed @dexterai/vault build: the blob builds, encodes to a ?req= value, and round-trips through decodeSpendGrantRequest, and the base58 validation is the library's own. Every other snippet on this page typechecks under strict against the installed @dexterai/x402 5.3.1 and @dexterai/vault .d.ts types. The tabFromGrantstreamclose calls run against Solana mainnet and the live facilitator, and against a session a real human approved — this page does not execute that leg for you.