keeperhub-tab
v0.1.12
Published
Spend control for onchain agents. Bounds who an agent pays and how much, and writes every decision to a record anyone can check.
Maintainers
Readme
keeperhub-tab
Spend control for KeeperHub agentic wallets. A drop-in PreToolUse hook that governs every payment your agent makes — a counterparty allowlist and a rolling budget, checked before anything signs — and hash-chains every decision so the record can be replayed by anyone.
KeeperHub's wallet checks each payment in isolation.
keeperhub-tabadds which counterparties this agent may pay, how much in total across a rolling window, and a record of every decision.
🔎 Live verifier · 📖 Docs · 📐 How it works · 💻 GitHub
Install
npm i keeperhub-tab @keeperhub/wallet@keeperhub/wallet is a peer dependency — Tab wraps the wallet you already have. Tab itself has zero runtime dependencies.
Quickstart — two lines
import { createTab } from 'keeperhub-tab';
const tab = await createTab({
agent: walletAddress,
counterparty: { allow: ['0x…the-service-you-buy-from'] },
budget: { capUsd: 5 },
});
export default tab.hook; // drop-in for KeeperHub's PreToolUse hookEvery payment now passes the allowlist and the budget before it signs, and every decision — KeeperHub's and Tab's — lands in a hash chain.
agent tries to pay
│
▼
┌──────────────────────────────────────────────────┐
│ KeeperHub hook → amount tiers · token allowlist│
│ a deny here is FINAL │
├──────────────────────────────────────────────────┤
│ tab.hook │
│ 1. counterparty → not vetted? ✗ deny │
│ 2. budget → over the cap? ✗ deny │
└──────────────────────────────────────────────────┘
│ │
✓ allowed every decision
▼ ▼
payment signs 🔗 hash chain → Tab.solTab may only tighten. KeeperHub's hook is consulted first and its denial is final; no Tab policy can turn a deny into an allow. That is what makes it safe to drop into an agent that already works.
createTab(opts)
The one-call setup. Composes KeeperHub's hook with Tab's policies, defaults the recorder, and returns a hook you can export directly.
What you pass:
| Option | Type | Default | |
| --- | --- | --- | --- |
| agent | string | required | Identifier recorded against every decision — usually the wallet address |
| counterparty | CounterpartyOptions | skipped | { allow: string[], onUnknown?: 'deny' \| 'ask' } — who the agent may pay |
| budget | BudgetOptions | skipped | { capUsd: number, windowMs?: number, store?: SpendStore } — cumulative cap |
| policies | Policy[] | [] | Extra policies, evaluated after the built-ins in array order |
| recorder | Recorder | in-memory | Where decisions are written. Use FileRecorder to survive a crash |
Omit counterparty or budget and that check is skipped entirely — there is no silent default cap.
What you get back:
const { hook, recorder, policies } = tab;
// └ export it └ read the chain └ what's active, in orderDefaults worth knowing: windowMs is 24h, onUnknown is 'deny' (a challenge with no readable recipient is refused, not guessed), and the built-ins always run counterparty before budget, so a payment to a stranger never consumes budget headroom.
Set it up from the terminal (no code)
Registers the hook in ~/.claude/settings.json and writes your policy to ~/.tab/config.json:
npx keeperhub-tab init 0x…the-service-your-agent-pays --cap=5
npx keeperhub-tab allow 0x…another-service
npx keeperhub-tab statusThe allowlist starts empty on purpose — a fresh install refuses every payment and tells you why, because a control that permits everything until configured is not a control.
Registration is surgical: it preserves every unrelated key and every sibling command already in your PreToolUse block.
Decisions
Every call returns KeeperHub's decision shape, so it is a true drop-in:
type Decision =
| { decision: 'allow' }
| { decision: 'ask'; reason?: string }
| { decision: 'deny'; reason: string };| Reason | Meaning |
| --- | --- |
| COUNTERPARTY_NOT_ALLOWLISTED | Recipient is not in allow |
| BUDGET_EXCEEDED | Payment would cross capUsd inside the window |
| RECIPIENT_UNDETERMINED | The challenge carried no readable recipient |
| AMOUNT_UNDETERMINED · AMOUNT_MALFORMED | The amount could not be read, or is not a valid non-negative integer |
| CHALLENGE_UNEVALUABLE | A 402 the signer seam could not parse — refused rather than paid blind |
| BLOCKED_BY_SAFETY_RULE · ASK_REQUIRED | Came from KeeperHub's own hook, passed through unchanged |
Unknowns fail closed. An amount or recipient Tab cannot read is refused, never defaulted.
The second seam — createGovernedSigner
The PreToolUse hook governs agent frameworks. A backend service, a cron job, or a non-Claude runtime that imports the package and calls paymentSigner.fetch() directly never fires a hook at all. createGovernedSigner applies the same policy at that seam:
import { createTab, createGovernedSigner, PaymentRefused } from 'keeperhub-tab';
const tab = await createTab({ agent, counterparty: { allow }, budget: { capUsd: 5 } });
const signer = createGovernedSigner({ hook: tab.hook });
try {
const res = await signer.fetch('https://api.example.com/paid-endpoint');
} catch (e) {
if (e instanceof PaymentRefused) console.error(e.reason, e.url);
}It reads the 402 challenge, rules on it, and pays that same response rather than re-fetching the URL. A server that quoted $0.01 to the check and $90 to the payment would otherwise pass a check that never saw the second quote.
Durable and cross-process storage
The defaults are in-memory, which is right for a single short-lived process and wrong for anything else.
import { FileRecorder, FileSpendStore } from 'keeperhub-tab';
const recorder = new FileRecorder('./.tab/decisions.ndjson'); // append-only, fsync per write
const store = new FileSpendStore('./.tab/spend'); // atomic across processes
const tab = await createTab({
agent,
counterparty: { allow: ['0x…'] },
budget: { capUsd: 5, store },
recorder,
});FileRecorder tolerates a torn final line from a crash mid-write, and refuses to load a log corrupted in the middle rather than silently continuing from a broken chain. FileSpendStore guards the ledger with an exclusive-create lock, so concurrent agent processes on one filesystem cannot both slip under the same cap.
For agents spread across machines, implement SpendStore against Redis or Postgres — nothing else changes:
interface SpendStore {
/** True if `amount` fit within the cap and was committed. Must be atomic. */
reserve(agent: string, amount: bigint, windowMs: number, capMicro: bigint): Promise<boolean>;
/** Total committed within the window. */
spent(agent: string, windowMs: number): Promise<bigint>;
/** Give headroom back when a later policy denies. */
release(agent: string, amount: bigint): Promise<void>;
}The reserve is provisional until the whole evaluation ends in an allow, so requests that are refused later — or that KeeperHub turns into an ask — return their headroom. Without that, anyone able to trigger refused payments could exhaust your budget without ever spending a cent.
Anchor the record on-chain
AutoAnchor commits the chain head on a cadence, so a compromised operator cannot quietly rewrite history:
import { AutoAnchor } from 'keeperhub-tab';
new AutoAnchor(recorder, sink, { everyDecisions: 50, everyMs: 300_000 }).start();Both triggers matter: a count threshold bounds the window during a busy run, a time threshold bounds it during a quiet one. Supply any sink that can write a head:
interface AnchorSink {
anchor(head: string, count: number): Promise<{ txHash: string }>;
}One transaction attests to every decision since the last anchor — the hash chain is what makes that batching safe. A reference sink using KeeperHub's DirectExecutor (which signs and sponsors the gas) is in the repository.
Exports
| | |
| --- | --- |
| createTab(opts) | One-call setup — policies, recorder, hook |
| createGovernedSigner(opts), PaymentRefused | The library seam, for callers with no framework hook |
| CounterpartyPolicy, BudgetPolicy | The individual policies, composable on their own |
| ChainedRecorder, ConsoleRecorder, FileRecorder, rewriteLog | Recorders, and the tamper helper the verifier demo uses |
| MemorySpendStore, FileSpendStore | Spend ledgers |
| AutoAnchor | Scheduled on-chain commitment |
| hashRecord, canonical, GENESIS | The hash chain, so you can verify a log yourself |
| extractIntent, usdToMicro, formatUsd | Challenge parsing and unit conversion |
| registerHook, loadConfig, saveConfig, configPath, DEFAULT_CONFIG, runCli | What the CLI is built from |
| Decision, Policy, Recorder, SpendStore, TabRecord, PaymentIntent, AnchorSink, … | Types |
Every export documented with parameters, types and examples → tab-verify.vercel.app/docs
Requirements
- Node 20+. Ships ESM and CommonJS —
importandrequireboth work, with types resolving under each condition. @keeperhub/walletas a peer.createTabcalls itscreatePreToolUseHook()unless you inject your own.- Amounts are handled as
bigintmicro-USDC end to end, so a value pastNumber.MAX_SAFE_INTEGERis compared exactly rather than rounded.
License
MIT
