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:
- the session secret key — the ed25519 key the agent signs vouchers with, and
- 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:
vaultPdais 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 totabFromGrant.swigAddress(the buyer's Swig state account) you do not need to supply.tabFromGrantreads 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
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.
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:
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.
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
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.
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.
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 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
- App calls
requestSpendGrant+encodeSpendGrantRequest, sends the consent URL to a human. - Human approves once on the Dexter consent page; the sponsor registers the session on chain.
- Agent receives
params+vaultPdafrom the callback. - Agent calls
tabFromGrantwith its session secret,params, andvaultPda. - Agent pays across calls with
tab.stream()ortab.signNextVoucher(). - 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 (requestSpendGrant → encodeSpendGrantRequest) 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 tabFromGrant → stream → close 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.