Integration surfaces
Three ways to put an Agent on the rail, and three to put a Service on it, in the order most people should try them.
The MCP server is the shortest path and needs one line of configuration. The CLI is what writes that line. The server plugins are what a Service meters through. The payment-strategy seam is the one you reach for when your Agent signs with something other than a plain key, and it is deliberately small.
Everything on this page runs as written against the hosted Testnet deployment.
For Mainnet, set MONAD_CHAIN_ID=143 and use the Mainnet addresses and gateway from the introduction.
Install
npx -y @tabai/sdk connectThat is the whole adoption step.
It finds your client's configuration file for your platform, backs the file up, merges a single mcpServers.tab entry, and prints the diff before it writes.
It knows claude-code, claude-desktop, cursor, windsurf and vscode, and takes --client <id>, --config <path> or --dry-run.
Run it twice and the second run writes nothing: the merge is idempotent, and it leaves no second backup.
It never writes a private key.
Signing keys are read from the environment when a Settlement is broadcast or a metered call is signed, and nothing about them is copied into a client configuration file.
Set AGENT_ADDRESS before running it and the entry carries the Agent too.
The hosted gateways meter only a signed call, so for tab_call the environment the client launches the server with also needs AGENT_PRIVATE_KEY.
Check the whole installation, keylessly, with:
npx -y @tabai/sdk doctordoctor reports what it could reach and warns rather than passing on what it could not.
A missing Agent address is a warning and not a failure, because the read-only tools work without one.
The command line
The same package is a CLI. Every command reads the chain; one of them writes to it, and it says so before it does.
| Command | What it does | Spends |
|---|---|---|
connect | Writes the tab entry into your MCP client's configuration | nothing |
mcp | Serves the four tools over MCP. This is what a client launches | nothing |
doctor | Checks the installation against the live deployment | nothing, and needs no key |
status | What an Agent owes, may still spend, and has settled | nothing, and needs no key |
settle | Pays down an Open Tab, with the Agent's key or, with --strategy monad-relayed, by a Permit2 signature the gateway submits | real funds, and only with --broadcast |
settle is a dry run unless you ask otherwise.
It needs a payment strategy: with no tab.config, AGENT_PRIVATE_KEY is enough, because the SDK then settles through the direct Monad strategy against the deployment's TabSettlement, and a tab.config below registers any other.
It checks that the Service accepts the Asset, quotes the amount, and stops:
npx -y @tabai/sdk settle \
--agent 0x1f6f…0542 \
--asset 10143:0x4802…4083 \
--service 0x7461…0000 \
--amount 47000Dry run. Nothing was broadcast.
chain id 10143
amount 47000
Add --broadcast to send it. This spends real funds.Amounts are always base units.
USDC has six decimals, so 47000 is 0.047 USDC.
Nothing in this system takes a decimal, holds a price, or consults a rate.
On Testnet the mock token is named mUSDC wherever a symbol is printed, so a receipt in it is never mistaken for one in Circle's USDC.
mcp serves over stdio by default, and over streamable HTTP with --http [--port <n>] [--host <h>] [--endpoint <p>].
tab.config
With nothing configured, the SDK reads the hosted registry read API for the chosen network and knows the hosted demo Service, tab.demo, at its hosted gateway.
Both are exported as TAB_HOSTED, anything you configure takes precedence over them, and TAB_HOSTED_DEFAULTS=off switches them off for a setup that must reach nothing it did not name.
The hosted gateway meters only a signed call, so with AGENT_ADDRESS and AGENT_PRIVATE_KEY set and no file at all, tab_call reaches tab.demo and signs each call with the Agent's own key.
The same key settles: with no strategy registered, tab_settle pays through the direct Monad strategy against the hosted deployment's TabSettlement, in that network's Assets.
A file is for everything beyond that: the Agent's address if you would rather not put it in the environment, any Services beyond the demo one (the chain records no URL for a Service), the payment strategies, and the x402 signer.
A tab.config.mjs beside your project, or any parent directory:
import { Wallet, JsonRpcProvider } from "ethers";
import { agentSignedMetering, createMonadStrategy, createRelayedMonadStrategy } from "@tabai/sdk";
const assets = {
"10143:0x480209747417f5c830fda188a9b9acfa70bc4083": {
chainId: 10143n,
address: "0x480209747417f5c830fda188a9b9acfa70bc4083",
decimals: 6,
symbol: "mUSDC",
},
};
// The Agent's signer, or nothing. Built only when something has to sign.
const agentSigner = () =>
process.env.AGENT_PRIVATE_KEY === undefined
? undefined
: new Wallet(process.env.AGENT_PRIVATE_KEY, new JsonRpcProvider(process.env.MONAD_RPC_URL));
export default {
// Whose Open Tab a metered call lands on.
agent: process.env.AGENT_ADDRESS,
// The chain records no URL for a Service, deliberately, so the address lives here.
// tab.demo is known without this entry; it is spelled out to add the Hub.
services: [
{
serviceId: "0x7461622e64656d6f000000000000000000000000000000000000000000000000",
name: "tab.demo",
endpoint: "https://gateway-testnet-production-a657.up.railway.app",
// The gateway meters only a signed call; the Agent signs its own.
headers: agentSignedMetering(agentSigner),
// The API Hub provider this Service fronts on credit. tab_discover lists
// its endpoints under the Service, priced by the Hub.
hub: { provider: "defillama", prefix: "apihub" },
},
],
// The x402 signer for the prepaid fallback: when a Service refuses a call on
// credit and its 402 carries an x402 offer, tab_call pays for that one call.
// Leave it out to never prepay.
x402: agentSigner,
// Factories, not objects: a signer is built only if something settles, so
// every read stays keyless. The direct path first; the gasless one is taken
// by id (--strategy monad-relayed) or when the direct one declines.
strategies: [
() => {
const signer = agentSigner();
if (signer === undefined) return undefined;
return createMonadStrategy({ signer, tabSettlement: process.env.TAB_SETTLEMENT_ADDRESS, assets });
},
() => {
const signer = agentSigner();
if (signer === undefined) return undefined;
return createRelayedMonadStrategy({
signer,
tabSettlement: process.env.TAB_SETTLEMENT_ADDRESS,
relayUrl: "https://gateway-testnet-production-a657.up.railway.app/relay/settle",
assets,
});
},
],
};The key is read from the environment at the moment a Settlement or an x402 payment is built, and never from this file.
tab_discover, tab_status and doctor never call a factory, which is what keeps every read on this rail keyless.
The relayed strategy needs one approve(Permit2, amount) on the Asset before its first use, and names that approval in its refusal when it is missing.
A factory may decline.
Returning undefined, as the one above does when no key is set, means "not available here" rather than "this config is broken": the strategy is skipped and everything else in the file stands.
That is the whole reason the entry is a factory rather than an object, and it is what lets one config file serve a read-only process and a settling one without branching.
With that file in place, AGENT_ADDRESS and MONAD_RPC_URL set and connect already run, the one warning doctor leaves is the missing signing key.
Supply AGENT_PRIVATE_KEY and it reports none.
As a dependency
connect is for a client.
To build against the rail directly:
npm install @tabai/sdkimport { createTabToolset, createTab402Client, createMonadStrategy, createRelayedMonadStrategy } from "@tabai/sdk";The package ships its own types and carries no workspace dependency, so it installs and type-checks on its own.
Beside the main entry point, @tabai/sdk/hub (the API Hub manifest reader), @tabai/sdk/metering (the metering-signature digest), @tabai/sdk/x402/client (the x402 client that pays a 402, which the registry buys its Nansen profiles with) and @tabai/sdk/settle (the direct Monad strategy and the auto-settle policy, which the Dashboard's Try it settles with) are pure entry points for a bundle that must not carry the tab.config file walk.
| Import | What it is |
|---|---|
createTabToolset | The four tools as plain functions, for embedding without MCP |
createTabMcpServer | The MCP server itself |
createTab402Client | An HTTP client that understands the post-paid 402 and its charge headers |
tabPostPaid, honoTabPostPaid, expressTabPostPaid, withTabPostPaid | Server plugins that meter a route after it has already responded |
createTabProxy | A reverse proxy that meters an upstream you do not control |
createMonadStrategy, createStrategyRegistry | The Monad payment strategy, and the registry that resolves strategies |
createRelayedMonadStrategy, signSettlementPermit | The gasless strategy: a Permit2 witness the Agent signs and a relay submits, and the signing helper on its own |
createKuruFundedStrategy, createKuruOnchainRouter | A strategy that swaps the shortfall in through Kuru before settling |
createIntentsFundedStrategy, createOneClickClient | A strategy that brings a USDC shortfall to Monad from another chain through NEAR Intents before settling |
createX402Client, createX402Facilitator, handlePrepaidRequest, exactRequirementFor | The x402 client and server halves, over Monad's facilitator |
createX402FrontedProxy, createX402UpstreamPricing, fetchHubManifest | Fronting an x402 upstream on credit, and reading the API Hub's manifest |
revertMappingFor | The table mapping a recordDelivery revert to a category, code, disposition and remedy |
TAB_HOSTED | The hosted registry read API and demo Service per network, which the MCP server falls back to |
The four MCP tools
Each tool declares a JSON Schema for its input and its output, and validates its own input against the schema it published.
None of them throws.
A failure returns ok: false with an error carrying a category, a code, a message and retryable, so a model can decide what to do next rather than parse an exception.
| Tool | What it does | Spends |
|---|---|---|
tab_discover | Lists Services, the Assets each accepts, what each tool costs, the Bond each has escrowed, and the API Hub endpoints a Service fronts | nothing, and needs no key |
tab_call | Calls a metered tool. The charge lands on the Open Tab and is settled later. With an x402 signer configured, a credit refusal that carries an offer is prepaid instead, and the output says so | nothing at call time, unless it prepays |
tab_status | The Agent's Credit Limit, Open Tab, prepaid credit and headroom per Asset, with recent Settlements | nothing, and needs no key |
tab_settle | Pays down an Open Tab by sending TabSettlement.settle with the Agent's own key, or a Permit2 signature through the relay | real funds |
Start with tab_discover, because tab_call needs a serviceId from its list.
The failure that matters
An Agent with no headroom is the one case worth handling by name.
{
"ok": false,
"error": {
"category": "LIMIT",
"code": "LIMIT_EXCEEDED",
"message": "this call needs 10000 base units and the Agent has 2500 of headroom for the Asset; settle with tab_settle and call again",
"retryable": false,
"requiredBaseUnits": "10000",
"headroomBaseUnits": "2500"
}
}Both figures are present only on LIMIT_EXCEEDED, and they are there so the answer is actionable.
The correct response is to settle, not to retry: retrying a call that exceeded a Credit Limit produces the same refusal at the same cost.
A Settlement restores headroom in the block it lands, so the retry after one succeeds.
tab_settle takes dryRun for exactly this moment.
It resolves the Service's Collection address, checks the Asset is accepted, and reports what it would broadcast without broadcasting it.
The answer names the strategy it would settle through as strategyId, and carries that strategy's own account of the Settlement as note: the gas, and for a funded strategy the shortfall and what it would bring in first.
Once a Settlement lands, tab_settle waits up to 20 seconds for the registry to index it and says whether it did as indexed, so a tab_status straight after reads the tab as paid rather than as it was a block earlier.
The other answer to a refusal is to pay for that one call.
When the Service's 402 carries an x402 PAYMENT-REQUIRED offer and tab.config names an x402 signer, tab_call signs an EIP-3009 authorization for the charge, repeats the request with PAYMENT-SIGNATURE, and returns the result with an x402 block naming the transaction.
Nothing lands on the Open Tab, because nothing is owed, and the payment is not a Settlement: it does not enter the credit history and does not raise the limit.
The On Monad page has the whole exchange.
Auto-settle
Set autoSettle: { atBaseUnits: "500000" } in tab.config, or TAB_AUTO_SETTLE_AT=500000, and tab_call pays the whole Open Tab once a call leaves it at that amount, and settles before retrying a call the Credit Limit refused, once.
Each automatic Settlement is the transaction tab_settle would send, from the Agent's own key, reported beside the call as an autoSettle block with its reason.
It is off unless set.
A new Agent starts at the baseline once it authorises a bonded Service
A Credit Limit is capped by the Bonds of the counterparties the Agent has authorised or settled with.
An Agent that authorises a bonded Service starts at min(baseline, 95% of that Service's free Bond).
Anything above the baseline is earned by settled history with at least three Curated, bonded Services.
An Agent that has authorised nobody has no counterparties and no limit.
Running a metered Service
Deliver first, charge after.
The plugin adds three status codes to your surface: a 402 when a charge would exceed the caller's Credit Limit, and the 403 and 409 below.
import { honoTabPostPaid, tabPostPaid } from "@tabai/sdk";
app.use("/meter/*", honoTabPostPaid(tabPostPaid({ serviceId, asset, priceOf, tabBook })));Tools are mounted under /meter/<tool> off the Service's published endpoint: that is where tab_call sends a call, where this repository's gateway serves them, and what leaves /hub/<prefix> beside the tools rather than under them.
The handler runs, the response is produced, and only then is the delivered work metered into the Agent's Open Tab with recordDelivery.
By default the response is held for that one record and released carrying six charge headers, which go out together or not at all: Tab-Charge-Amount, Tab-Charge-Asset, Tab-Charge-Service, Tab-Charge-Tool, Tab-Open-Tab and Tab-Headroom.
With release: "before-metering" the response goes out first and the record lands behind it, which is the shape for a Service that will not accept even one block of latency.
Three refusals reach the Agent, because only the Agent can clear them.
| Status | Revert | What clears it |
|---|---|---|
402 | LimitExceeded | a Settlement. The body and the headers carry the required amount and the headroom |
403 | AuthorisationMissing, AuthorisationExpired, AuthorisationExceeded | a new TabBook.authorise |
409 | TabIsDelinquent | a Settlement that closes the tab |
Every other revert is the Service's fault, and the plugin delivers the handler's own response unchanged rather than handing the cost to the caller. A Service whose billing is broken pays for the delivery.
A Service may put an x402 offer beside the 402.
exactRequirementFor builds the requirement for the same charge, paymentRequiredHeaders puts it on the refusal, and handlePrepaidRequest answers a request that comes back carrying PAYMENT-SIGNATURE: verify through the facilitator, deliver, settle through the facilitator, answer with PAYMENT-RESPONSE.
A payment that does not verify delivers nothing, and a delivery that failed is never settled.
A prepaid request needs no metering signature: it carries its payment, which is what the offer is for.
The metering gateway in this repository is that plugin with two things the SDK cannot know added: which chain to talk to, and who is allowed to ask.
Every metered route requires a signature over a digest of the method, the path, the Agent, the tool, the unit count and a timestamp, because a gateway that holds an operator key and meters unauthenticated requests would let any stranger charge any Agent up to its whole authorisation ceiling.
Three parties may give it, and all three sign the same digest.
The Service's own front signs with the operator key in Tab-Operator-Signature, having authenticated its caller however it likes.
Or the Agent signs with its own key in Tab-Agent-Signature, recovered against Tab-Agent, which is the shape a gateway on the open internet takes: the one party that pays for the call is the one that asked for it.
Or a delegate signs: a session key the Agent registered in MeteringDelegates with setDelegate(delegate, expiry), for an Agent whose wallet can submit a transaction but cannot sign a message.
The call then carries Tab-Delegate, Tab-Delegate-Signature and Tab-Delegate-Issued-At; the gateway recovers the signature against Tab-Delegate, then checks MeteringDelegates.isDelegate(agent, delegate) on chain, and the claim still names the Agent, so the charge lands on the Agent's Open Tab as it always would.
The timestamp window is the same five minutes for all three.
agentSignedMetering in tab.config is the Agent's side of that, one line on the Service entry, and it builds the key only when a call is made; delegateSignedMetering is the same for a registered delegate.
import { agentSignedMetering } from "@tabai/sdk";
services: [{ serviceId, endpoint, headers: agentSignedMetering(agentSigner) }]A delegate is bounded by construction.
It signs metering claims and nothing else: no Settlement path reads MeteringDelegates, so it can never move the Agent's funds.
Every charge it causes still passes TabBook.recordDelivery, which holds it to the Agent's own TabBook.authorise ceiling and expiry for that Service and Asset and to the Credit Limit.
Its expiry is required and at most a year ahead, and the Agent revokes it with one transaction, revokeDelegate(delegate).
The gateway reads MeteringDelegates on every delegate-signed call and holds no answer by default, so a revocation takes effect on the next call.
It accepts delegates only when METERING_DELEGATES_ADDRESS names the contract; without it a delegate-only call is refused with METERING_DELEGATE_UNSUPPORTED and the other two paths are unchanged.
Both hosted gateways name it, on Testnet and on Mainnet.
The Agent Wallet plugin's mm tab delegate is the reference user of this path, and it has run end to end on Mainnet.
Before it broadcasts, the gateway proves the witness against TabBook.historyCommitment and simulates the call, so a delivery that would revert is never paid gas for.
The history behind that witness is held between calls and read from the registry's GET /agents/:agent/witness/:asset on first sight, so a metered call costs one commitment read and not a log scan from the deployment block.
Every transaction the operator key sends, whether metering, a relayed Settlement or a reputation entry, goes through one send queue that counts its own nonces, so overlapping calls never race for one.
A read-only RPC request is retried twice on a transport failure or a rate limit, and a write or a revert is never retried.
The client side of the 402
import { createTab402Client } from "@tabai/sdk";
const client = createTab402Client({ baseUrl: "https://gateway-testnet-production-a657.up.railway.app", agent });
const result = await client.fetch("/meter/quote.generate");The client sends Tab-Agent, parses the same six headers on a success and on a 402 with the same parser, and keeps a running snapshot per Asset of what it has accrued, what was declined, and the headroom the Service last reported.
It repeats a request once after a 402, never more, because a retry loop against a credit decision is the mistake the figures in the body exist to prevent.
It adds no metering signature, so against a gateway that requires one, such as the hosted tab.demo, pass Tab-Agent-Signature and Tab-Agent-Issued-At (or a registered delegate's three headers) in the request headers, or call through tab_call with agentSignedMetering.
It does not settle on its own.
A 402 that still stands comes back as a LIMIT_EXCEEDED result carrying the two figures, and the decision to settle stays with the Agent.
Metering an upstream you do not control
createTabProxy forwards a request to an upstream, meters it on the way back, and runs hooks around it.
A hook is a named pair of optional phases: before runs ahead of the forwarded request in registration order, and after runs once the response exists in reverse order, the same nesting a middleware stack has.
Every phase returns a Result, and a hook that fails is logged and skipped unless it declared critical: true, because a Service whose log shipper is down should still deliver work.
A hook that looked a Settlement up on chain can attach it to the request context, with its settlementId, applied and toPrepaid figures straight off the Settled event.
Fronting an x402 upstream on credit
createX402FrontedProxy is the reverse of the offer above and the thing that puts Tab on top of pay-per-request APIs.
The Agent calls the Service on credit; the Service forwards to an x402 upstream, reads the price off the upstream's 402, pays it with its own key, and meters the Agent's Open Tab for that price plus a margin.
The Agent never signs and never holds the upstream's currency.
import { createX402FrontedProxy, createX402UpstreamPricing, tabPostPaid } from "@tabai/sdk";
const pricing = createX402UpstreamPricing({ tool, margin: { bps: 500n } });
const metering = tabPostPaid({ serviceId, asset, tabBook, priceOf: pricing.priceOf });
const proxy = createX402FrontedProxy({
upstream: "https://x402.monid.ai/v1",
stripPrefix: "/hub/apihub",
signer: operatorWallet,
asset,
pricing,
metering,
preflight: async ({ agent, amount }) => simulateDelivery(agent, amount),
});
app.all("/hub/apihub/*", (c) => proxy(c.req.raw));preflight runs between the upstream's 402 and the operator's signature, with the quoted amount in hand, so an Agent with no headroom is refused before the Service's own funds move.
A fronted tool must be priced on chain like any other, and because its price is not known until the upstream answers, the amount rides in the unit count: the Service publishes one base unit per unit and a call consumes as many as it cost.
An unpriced fronted tool reverts UnknownTool, the plugin delivers the work anyway, and the Service pays the upstream and bills nobody.
The API Hub takes USDC on Monad Mainnet and nothing else.
A Mainnet Service pays it on its own chain; a Testnet Service names that chain and Asset in upstreamPayment and pays there with a key funded there, while the Agent is metered on Testnet in the Service's own Asset, base unit for base unit.
The metering gateway mounts the API Hub at /hub/apihub and Nansen at /hub/nansen from GATEWAY_HUB_UPSTREAMS, each with a payOn naming Mainnet USDC, and tab_discover lists a fronted provider's endpoints from the Hub's manifest.
Relaying gasless Settlements
POST /relay/settle on the gateway takes the body createRelayedMonadStrategy produces: the Agent, the Service, the Asset, the amount, a nonce, a deadline and the Permit2 signature.
It checks the chain and the surface, simulates settleWithPermit2 from the operator, maps every revert to a named refusal (PERMIT_SIGNER_MISMATCH, PERMIT_NONCE_USED, PERMIT_EXPIRED, and the contract's own), and only then submits and waits for the receipt.
The reply carries the transaction hash and the settlementId, applied and toPrepaid figures off the Settled event, the same receipt the direct strategy returns.
The relayer pays gas and holds nothing: the Asset moves from the Agent to the Collection address inside the Settlement, exactly as it does on the direct path.
The payment-strategy seam
On Monad a tab is paid through TabSettlement, by settle with the Agent's key or by settleWithPermit2 with its signature, and the shipped strategies do one each with an ethers signer.
The seam exists so that a different signer, a smart account, a session key, or a test double can stand behind the same call without the SDK caring which.
createKuruFundedStrategy shows the seam used the other way: it wraps a strategy and tops the Agent's balance of the Asset up through Kuru before delegating.
A strategy is five members and an optional sixth:
export interface PaymentStrategy {
readonly id: string;
readonly chainIds: readonly bigint[];
supports(asset: AssetRef): boolean;
quote(request: ChargeRequest): Promise<Result<ChargeQuote>>;
settle(request: SettleRequest): Promise<Result<SettlementReceipt>>;
settleBatch?(requests: readonly SettleRequest[]): Promise<Result<readonly SettlementReceipt[]>>;
}A receipt carries the transaction hash and, once the receipt is read, the settlementId, applied and toPrepaid figures from the Settled event.
settleBatch maps onto TabSettlement.settleBatch, which pays several tabs in one transaction, and it should stay undefined where the signer behind the strategy has no batch form.
Funding from another chain
createIntentsFundedStrategy is the same wrapping with a different source of funds: USDC the Agent holds on another chain.
Before every Settlement it reads the Agent's Monad balance of the Asset.
When the balance covers the amount it delegates straight to the inner strategy.
When it is short, it asks the NEAR Intents 1Click API for an EXACT_OUTPUT quote of the shortfall delivered to the Agent's own Monad address, with a failed or expired deposit refunded to the Agent's address on the funding chain.
It refuses a quote above maxFundingAmount, transfers the quoted USDC to the deposit address on the funding chain, polls the status until SUCCESS, and checks that the Monad balance now covers the amount before it settles.
Settlement is same-chain and atomic by design, and this does not change it.
The funding step only fills the Agent's own balance on Monad; the Settlement is still one Monad transaction that moves the Asset to the Service and applies it to the tab together.
A funding step that is refunded, fails or times out returns a Result naming the deposit address and its last status, and no Settlement is sent.
import { JsonRpcProvider } from "ethers";
import { createIntentsFundedStrategy, createMonadStrategy, ONE_CLICK_USDC_FUNDING } from "@tabai/sdk";
const strategy = createIntentsFundedStrategy({
inner: createMonadStrategy({ signer, tabSettlement, assets }),
signer,
intents: {
funding: ONE_CLICK_USDC_FUNDING.base,
fundingSigner: signer.connect(new JsonRpcProvider(process.env.INTENTS_FUNDING_RPC_URL)),
maxFundingAmount: 5_000_000n,
},
});Its id is intents-funded, which tab_settle takes as strategyId and the CLI as --strategy intents-funded.
The repository's tab.config.mjs builds it on Mainnet when INTENTS_FUNDING_CHAIN (base, arbitrum, ethereum, optimism, polygon or avalanche) and INTENTS_FUNDING_RPC_URL are set, with INTENTS_FUNDING_MAX_BASE_UNITS as the ceiling and an optional NEAR_INTENTS_API_KEY.
Only Mainnet USDC is supported, because it is the one Tab Asset NEAR Intents delivers on Monad; Testnet mUSDC is declined and resolves to another strategy.
A dry run asks only for a dry: true quote, which creates no deposit address, so it moves nothing.
It has run live on Mainnet: owing 0.20 USDC with 0.08868 on Monad, the Agent deposited 0.113481 USDC on Arbitrum, NEAR Intents delivered the 0.11132 shortfall to its Monad address in block 109379795, and the Settlement of 0.20 landed in block 109379821.
The slippage tolerance is spent, not refunded: that run paid 0.113481 for 0.11132 delivered.
Of the 0.002161 difference, 0.001125 was the 1% tolerance and the rest the route's own fees, about 1.9% in all on a small amount; a larger shortfall spreads the fixed fees thinner, and a lower slippageBps shrinks the tolerance.
The On Monad page has the three transactions.
A strategy over a different signer
import { createMonadStrategy, createStrategyRegistry } from "@tabai/sdk";
const sessionKeyStrategy = createMonadStrategy({
signer: mySessionKeySigner, // anything with an ethers Signer's shape
tabSettlement: process.env.TAB_SETTLEMENT_ADDRESS,
assets,
});
const registry = createStrategyRegistry([sessionKeyStrategy]);quote never converts.
Tab records integer base units of one Asset and holds no price, rate or oracle anywhere, so a quote is the request's own amount handed back.
Nothing here returns a boolean for success.
Every fallible call returns a Result, and a strategy that throws will take down the caller that trusted it.
An Agent key in a Privy server wallet
An Agent's key in its own environment is the whole of its authority: whoever reads the variable can sign anything, on any chain, to anyone.
createPrivyAgentSigner moves the key into a Privy server wallet instead.
The key never enters the Agent's process, and every signature is a request to Privy's wallet API that Privy's policy engine checks before it signs.
So even a compromised Agent process can sign only what the policy allows, and on Tab that is paying its own tabs.
The signer is an ethers AbstractSigner, so it goes wherever the SDK takes a signer, and each thing an Agent signs becomes one Privy RPC method.
| What the Agent signs | Privy RPC method |
|---|---|
A metering claim, through agentSignedMetering | personal_sign |
A gasless Settlement, through createRelayedMonadStrategy | eth_signTypedData_v4 under Permit2's domain |
authorise, approve and settle, through createMonadStrategy and the scripts | eth_signTransaction, then broadcast through the Agent's own Monad provider |
Transactions are signed by Privy and broadcast through the provider you pass, because eth_signTransaction takes an explicit chain id and works on any EVM chain.
transactions: "privy" has Privy send them instead, with eth_sendTransaction on eip155:143 or eip155:10143.
Every signature is recovered and every signed transaction decoded and compared with the request before it is used, so a wallet id that points at the wrong wallet fails in the SDK and not on chain.
The tab.config entry
The demonstration tab.config.mjs does this whenever PRIVY_WALLET_ID is set, and falls back to AGENT_PRIVATE_KEY only when it is not.
import { JsonRpcProvider } from "ethers";
import { agentSignedMetering, createMonadStrategy, createPrivyAgentSigner, createRelayedMonadStrategy } from "@tabai/sdk";
// Built once, with no network call; the first signature reads the wallet's address.
const privy = createPrivyAgentSigner({
appId: process.env.PRIVY_APP_ID,
appSecret: process.env.PRIVY_APP_SECRET,
walletId: process.env.PRIVY_WALLET_ID,
authorizationKey: process.env.PRIVY_AUTHORIZATION_KEY,
chainId: 10143n,
provider: new JsonRpcProvider("https://testnet-rpc.monad.xyz", 10143, { staticNetwork: true }),
});
const agentSigner = () => (privy.ok ? privy.value : undefined);
export default {
agent: process.env.TRY_IT_AGENT, // the wallet's address
services: [{ serviceId, endpoint, headers: agentSignedMetering(agentSigner) }],
strategies: [
() => createMonadStrategy({ signer: agentSigner(), tabSettlement, assets }),
() => createRelayedMonadStrategy({ signer: agentSigner(), tabSettlement, relayUrl, assets }),
],
};createPrivyAgentSigner returns a Result, so a missing app id or an unreadable key is a named refusal at load rather than an exception at the first call.
A refusal from Privy's policy surfaces as PRIVY_POLICY_DENIED, as the cause of the strategy's own error, and its message names the request that was refused (for example transfer(0x…dEaD, 1) on the Asset) and the rules the wallet's policy has for that method.
The policy
buildPrivyAgentPolicy writes the policy for one chain from the deployment's addresses, and Privy refuses any request no rule allows.
| Privy method | Allowed only when |
|---|---|
eth_signTransaction, eth_sendTransaction | the chain is this one, the value is zero, and the call is settle or settleBatch on TabSettlement, authorise on TabBook, approve on an accepted Asset with TabSettlement or Permit2 as the spender, or invalidateUnorderedNonces on Permit2 |
eth_signTypedData_v4 | the domain is Permit2 on this chain and the message is a PermitWitnessTransferFrom whose spender is TabSettlement |
personal_sign | the message starts with tab-metering-request, the first line of every metering claim |
A Permit2 signature the policy lets through can pay only a Settlement, because TabSettlement checks the witness (Service, Asset, amount, chain) before Permit2 moves anything.
A metering claim can only put a call on this Agent's own tab, because the gateway recovers it against the Agent.
The x402 prepaid fallback is refused by default, since it pays a party the policy cannot name in advance; x402MaxBaseUnits adds a rule for EIP-3009 authorisations on an accepted Asset up to that many base units each.
Who can change the policy
A Privy wallet's owner can change its policies and signers and export its key; an additional signer can only ask for signatures, within its own policy.
createPrivyAgentWallet uses that split.
It creates the policy and the wallet owned by an owner key, and adds the Agent's own authorization key as a signer bound to the same policy.
The Agent's machine holds the app secret and the signer key, neither of which can loosen the policy, and the owner key never needs to be there.
node --env-file=.env scripts/privy-agent.mjs --create # prints the policy; sends nothing
node --env-file=.env scripts/privy-agent.mjs --create --broadcast # creates it, writes both keys to ~/.tab/privy
node --env-file=.env scripts/privy-agent.mjs --denied --broadcast # Privy refusing a transfer to a stranger
node --env-file=.env scripts/privy-agent.mjs --loop --broadcast # authorise, a signed tab_call, tab_status, tab_settle--chain 143 creates a Mainnet wallet, and --strategy monad-relayed on --loop settles through the Permit2 relay.
This has run on Testnet against a real Privy app.
--create --broadcast created the policy, the signer and the wallet, --denied got Privy's own policy_violation, and --loop settled both directly and through the relay, with every metering claim signed by personal_sign, until the tabs were back at zero.
What it needs, and what it does not do
It needs a Privy app: the app id and secret come from Privy's dashboard, and nothing here works without them.
Privy enforces the policy off chain, in its own signing enclave, at the moment it is asked to sign; the chain knows nothing of it, and a key exported by the wallet's owner is bound by nothing.
What the policy adds is a limit on the Agent's credentials, not a new rule on the rail: TabSettlement and TabBook treat a Privy wallet exactly as they treat any other account.
Environment
The full contract is .env.example at the repository root, which is checked against every process.env read in the tree, so it cannot drift.
An integrator needs a much smaller set than a developer does.
| Variable | Needed for |
|---|---|
MONAD_RPC_URL | doctor's chain checks, and the provider a signer in tab.config sends through; the network's public endpoint when unset. The MCP tools read through the registry read API and need no RPC |
MONAD_CHAIN_ID | 143 for Mainnet, 10143 for Testnet. Defaults to Testnet |
MONAD_EXPLORER_URL | the links a page or a receipt prints. Never read by the contracts or the SDK's chain calls |
TAB_BOOK_ADDRESS, TAB_SETTLEMENT_ADDRESS, SERVICE_REGISTRY_ADDRESS, BOND_ADDRESS | resolving credit, Services and Bond |
MOCK_USDC_ADDRESS, USDC_ADDRESS | the Testnet Assets: the mock for demos and tests, and Circle's |
PERMIT2_ADDRESS | the gasless path. A network constant, the same on Testnet and Mainnet |
NEXT_PUBLIC_REGISTRY_API_URL | the Service directory and the Agent's history. Defaults to the hosted read API for the chosen network |
TAB_HOSTED_DEFAULTS | off stops the hosted read API and demo Service from filling in what you did not configure |
AGENT_ADDRESS | the Agent tab_call meters against, when tab.config names none. Public, so connect copies it into the MCP stanza |
AGENT_PRIVATE_KEY | broadcasting a Settlement, signing a Permit2 witness, paying an x402 offer, or signing a call to the hosted demo Service, and nothing else. Never written to a configuration file |
PRIVY_APP_ID, PRIVY_APP_SECRET, PRIVY_WALLET_ID, PRIVY_AUTHORIZATION_KEY | an Agent key held in a Privy server wallet instead of AGENT_PRIVATE_KEY. The secret and the authorization key are never written to a configuration file |
GATEWAY_PRIVATE_KEY | the Service operator key a gateway meters, relays and pays upstreams with. A Service needs it; an Agent never does |
X402_ENABLED, X402_FACILITATOR_URL | the offer on a 402 and the prepaid path, and the facilitator that settles them |
GATEWAY_HUB_UPSTREAMS, GATEWAY_X402_PRIVATE_KEY | the upstreams the gateway fronts, and an optional dedicated key that pays them |
GATEWAY_RELAY_ENABLED | false turns /relay/settle off. On whenever TAB_SETTLEMENT_ADDRESS is set otherwise |
METERING_DELEGATES_ADDRESS | the MeteringDelegates contract a gateway checks a delegate-signed call against, and the one mm tab delegate registers in. The plugin defaults to the recorded address on each network |
GATEWAY_REPUTATION_ENABLED, GATEWAY_PUBLIC_URL, GATEWAY_REPUTATION_INTERVAL_MS | ERC-8004 feedback about the paying Agent after each Settlement, off unless true; the public URL the feedback documents are served under at /reputation/:settlementId; how often new Settlements are looked for. See On Monad |
The deployed addresses were read back off the chain by a script that holds no key, so they are checkable by anyone with an RPC endpoint rather than only by whoever deployed them.
What the rail refuses
The refusals that matter to an integrator are these: revertMappingFor classifies the recordDelivery ones, and the gateway's relay names the Permit2 ones.
| Revert | Where | What it establishes |
|---|---|---|
NotSettlementSurface | TabBook.applySettlement | nothing but the wired TabSettlement can lower a tab |
PriceListChangedMidCall | recordDelivery | a Service cannot quote one price and meter another |
AuthorisationExceeded | recordDelivery | a Service cannot meter past the ceiling the Agent set |
HistoryCommitmentMismatch | any limit read | a witness with a record added, dropped or edited is refused |
IneligibleBondEntry | any limit read | a witness cannot cite a Bond the Agent has no relationship with |
SettlementWindowOpen | markDelinquent | a tab cannot be marked before its window has closed |
InvalidSigner, InvalidNonce, SignatureExpired | settleWithPermit2, from Permit2 | a Permit2 witness is single-purpose: the wrong signer, a spent nonce or a passed deadline is refused before anything moves |
Of those, only the authorisation ceiling is the Agent's to raise. The rest are the Service's, and where one of them stops a delivery from being metered the plugin delivers the handler's response anyway.
Check any of this yourself
pnpm --filter @tabai/contracts testruns the 179 contract tests, and the verification script in the contracts package reads every deployed address back from the chain. No private key, no funded account, no write.