Dexter
Dexter
Docs
Tabs

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

Tabs from a browser

By the end of this page you will have a web app that signs a user into their Dexter passkey wallet, opens one tab against their vault under a spend cap they approve, pays a paid API across many calls without a prompt on each one, and closes the tab to settle on chain and revoke the session.

This is the passkey path. The user holds their own keys. Their face (or fingerprint) authorizes the cap once, and a memory-only session key signs every request after that. Two packages do the work:

  • @dexterai/connect runs the passkey sign-in and hands you the vault plus a passkey signer.
  • @dexterai/x402/tab turns that signer into a vault adapter, opens the tab, and drives spend and settlement.

If you want the headless equivalent (an agent that never has a browser), see Tabs from a Node agent. For what a tab is and how it differs from an MPP session, see What is a tab.

Install

npm install @dexterai/connect @dexterai/x402 @dexterai/vault @solana/web3.js react

react and @dexterai/vault are required peers of @dexterai/connect. @solana/web3.js is required by the tab adapter.

1. Sign the user in

Drop in <SignInWithDexter/>. Signed out it renders the branded button; connected it becomes the wallet chip. The onSuccess payload carries the auth session and the vault.

ConnectButton.tsx
import { SignInWithDexter } from '@dexterai/connect/react';
import type { SignInResult } from '@dexterai/connect';
 
export function ConnectButton() {
  return (
    <SignInWithDexter
      onSuccess={(result: SignInResult) => {
        const vault = result.vault;
        if (!vault) return; // session-only until the vault payload is attached
        // vault.swigAddress = the Dexter Wallet address (base58)
        // vault.vaultPda    = the on-chain gate account
        // vault.publicKey   = 33-byte SEC1 P-256 authority key (base64)
        console.log('wallet', vault.swigAddress, vault.vaultPda);
      }}
    />
  );
}

The ceremony works on any origin. On dexter.cash it runs in-page; on any other site it runs in a hosted popup on dexter.cash/connect and posts the result back (origin-checked and nonce-bound). You do not configure this. The default transport picks the mode.

If you want the values imperatively instead of the component, useSignInWithDexter() exposes the same result plus vaultAddress, vaultPda, credentialId, and signIn() / disconnect().

2. Build the passkey signer

Build the signer from the connected vault with createPasskeySigner. It returns the DexterApiBrowserPasskeySigner the tab adapter consumes with no shim.

signer.ts
import { createPasskeySigner } from '@dexterai/connect';
import type { ConnectVault } from '@dexterai/connect';
import { createSolanaVaultAdapter } from '@dexterai/x402/tab/adapters/solana';
import { Connection } from '@solana/web3.js';
import type { Signer } from '@solana/web3.js';
 
export function adapterFromVault(vault: ConnectVault, connection: Connection, feePayer: Signer) {
  const passkeySigner = createPasskeySigner(vault); // DexterApiBrowserPasskeySigner
  return createSolanaVaultAdapter({
    connection,
    swigAddress: vault.swigAddress,
    vaultPda: vault.vaultPda,
    passkeySigner,
    feePayer,
  });
}

useSignInWithDexter() also exposes a passkeySigner field. In @dexterai/[email protected] its own type doc marks that field as landing next, so build the signer with createPasskeySigner(vault) rather than relying on the hook field being populated.

3. Open the tab under a cap

openTab calls the adapter's authorizeSession once. That is the single passkey prompt: the user endorses a session key scoped to this seller, this per-request cap, this total cap, and this expiry. perUnitCap and totalCap are the cap the user approves. After this call the session key signs every voucher and the passkey is not invoked again for the life of the tab.

open.ts
import { openTab } from '@dexterai/x402/tab';
import type { VaultAdapter } from '@dexterai/x402/tab';
 
export async function openCappedTab(vault: VaultAdapter, seller: string) {
  return openTab({
    vault,
    network: 'solana:mainnet',
    seller,
    perUnitCap: '0.01', // max USDC a single voucher may add
    totalCap: '2.00',   // max USDC across the whole tab
    sessionDuration: 3600,
    onLiveSession: 'error', // refuse to strand an existing live session
  });
}

Amounts are USDC human strings. The session PDA is keyed by (vault, seller), so one seller gets one live tab. onLiveSession: 'error' is the safe default: if a live tab already exists for this pair, openTab throws LiveSessionExistsError rather than silently replacing it and stranding the old tab's unsettled vouchers.

4. Spend within the tab

tab.stream(url) runs a paid request and returns the response body as an async iterable. The seller demands a fresh session-signed voucher before each chunk, so the buyer is charged exactly for what they receive. No passkey prompt fires here.

spend.ts
import type { Tab } from '@dexterai/x402/tab';
 
export async function readThroughTab(tab: Tab, url: string): Promise<string> {
  const body = await tab.stream(url);
  const decoder = new TextDecoder();
  let out = '';
  for await (const chunk of body) {
    out += decoder.decode(chunk, { stream: true });
  }
  console.log('spent', tab.state.spent, '/ remaining', tab.state.remaining);
  return out;
}

tab.state re-reads after every voucher exchange. state.spent is the cumulative USDC on this tab; state.remaining is the headroom left under totalCap. When the cap or expiry would be exceeded the iterable throws instead of streaming further.

5. Close: settle and revoke

tab.close() posts the final cumulative voucher through the facilitator, which settles USDC from the vault to the seller on chain, then revokes the session. For a passkey-opened tab the revoke is passkey-signed, so sessionRevoked comes back true.

close.ts
import type { Tab } from '@dexterai/x402/tab';
 
export async function closeTab(tab: Tab) {
  const result = await tab.close();
  // result.settledAmount  — cumulative USDC settled on chain
  // result.settleTx       — the settlement signature
  // result.sessionRevoked — true for an openTab tab (the passkey signs the revoke)
  return result;
}

After close, the vault's request_withdrawal is unblocked (the on-chain pending-voucher count returns to zero). Settling once at close is the point of a tab: many requests, one settlement transaction.

The fee-payer boundary

createSolanaVaultAdapter requires a feePayer: Signer. Register and revoke are on-chain transactions and something has to pay the lamport fee. The passkey signature in the transaction authorizes the session; the fee payer holds zero authority over the vault, it only pays the network fee.

A passkey-only browser wallet has no raw Solana keypair, so it cannot be its own fee payer. Two honest options:

  • Run the tab open through the hosted dexter.cash surface, which sponsors the fee payer for you. That ceremony is hosted, so there is no client snippet to paste here for it.
  • Supply your own funded Signer as feePayer from a hot wallet your site controls. This works because the fee payer cannot move vault funds.

Do not fabricate a fee payer with a fresh in-browser keypair. It will not be funded and the register transaction will fail. Decide up front which of the two paths above your app uses.

Errors to handle

All three are runtime classes exported from @dexterai/x402/tab, so instanceof works.

errors.ts
import { openTab, LiveSessionExistsError } from '@dexterai/x402/tab';
import { SessionScopeExceededError, TabClosedError } from '@dexterai/x402/tab';
import type { OpenTabOptions } from '@dexterai/x402/tab';
 
export async function openGuarded(opts: OpenTabOptions) {
  try {
    return await openTab(opts);
  } catch (e) {
    if (e instanceof LiveSessionExistsError) {
      // A live tab already exists for this (vault, seller). Settle the old
      // tab first, or retry with onLiveSession: 'replace'.
      console.log('live tab frontier', e.details.frontierAtomic);
    }
    throw e;
  }
}
 
export function classifySpendError(e: unknown): string {
  if (e instanceof SessionScopeExceededError) return e.reason; // 'cap_exceeded' | 'expired' | 'wrong_counterparty'
  if (e instanceof TabClosedError) return `closed:${e.channelId}`;
  return 'other';
}
  • LiveSessionExistsError carries details read from the live session PDA, including frontierAtomic (everything the chain has terminally counted). Replacing a live session strands any voucher the old key signed beyond that frontier, which is why the default refuses to replace silently.
  • SessionScopeExceededError.reason tells you whether you hit the cap, the expiry, or the wrong counterparty.
  • TabClosedError means you operated on a tab after close().

The distinct per-call payment error taxonomy (PayResult.reason, including the never-blind-retry cases) lives on Errors and retry safety.

What is hosted, and what you cannot fake

  • The passkey ceremony itself runs in the browser (navigator.credentials.get) but is brokered by dexter-api challenge and verify endpoints. On a foreign origin it runs in the hosted dexter.cash/connect popup.
  • Sponsoring the register and revoke fee payer on dexter.cash is a hosted step. This page does not show a sponsored-register snippet because there is no client-side call that produces it; either use the hosted surface or bring your own feePayer.

Everything else on this page is a real call against the installed packages. Every code block was typechecked against @dexterai/[email protected], @dexterai/[email protected], and @dexterai/[email protected].

The always-on rail has its own off switch

The capped tab above is one explicit session you open and close. Separately, a wallet can arm an automatic agent-spend rail that pays under standing daily and per-call limits. That rail is turned off with revokeAgentSpend, not tab.close().

revoke-automatic.ts
import { revokeAgentSpend } from '@dexterai/connect';
import type { AgentSpendIdentity } from '@dexterai/connect';
 
export async function turnOffAutomaticSpend(args: {
  identity: AgentSpendIdentity; // { kind, userHandle } — useIdentity()'s result satisfies this
  vaultPda: string;
  credentialId: string;
}) {
  return revokeAgentSpend(
    args.identity,
    args.vaultPda,
    'https://api.dexter.cash',
    args.credentialId,
  );
}

revokeAgentSpend takes effect on the next agent payment. Read the two rails together with assembleAgentSpendStatus, which returns the vault balance, the automatic rail state, and the list of open tabs.

On this page