In plain words
The SDK lets a program you write use your agent's bank directly. If your agent is written in JavaScript or TypeScript (the language Node.js runs), it imports one class, H081, and calls methods such as balance(), withdraw() or pay().
There is no server to start and nothing hosted: the code runs inside your program, on your computer, with the agent key in ~/.h081/agent.json. Like the command line, every method that moves money returns a plan and sends nothing until you pass confirm: true.
Not using JavaScript? The local API gives the same bank to any language.
Install and first script
You need Node.js 20.11 or newer. The package is an ES module: use import, in a .mjs or .mts file or a project with "type": "module".
Add the package to your project
in your project folder npm install @h081lll/agentCreate the agent key, once
This writes the key and settings into
~/.h081. Skip it if you already did it for the command line or Claude: they all share the same key.any terminal npx -y @h081lll/agent init --rpc <your Solana RPC URL>Then send 0.05 SOL and a few USDG to the wallet it prints (0.15 SOL if you will give budgets or lend).
Open the bank and read the balance
first.mts // first.mts import { H081 } from '@h081lll/agent'; const bank = await H081.open(); // reads ~/.h081/agent.json, ~/.h081/config.json and the environment console.log(bank.address()); // { wallet, addressCode } console.log(await bank.balance()); // { shielded, wallet: { usdg, sol }, allowances, ... } bank.close();run it (tsx runs TypeScript files directly) npx tsx first.mts
H081.open()
H081.open(options?) loads the agent and returns an H081 object. It works offline: the Solana RPC is reached only when a method needs the chain. If there is no key it throws, so run init first.
Where each setting comes from, first match wins:
| Setting | Read from |
|---|---|
| The agent key | H081_SOLANA_KEYPAIR (a file path), then H081_SOLANA_KEY (the key itself), then ~/.h081/agent.json |
| RPC, network, port | The environment (H081_RPC_URL, H081_CLUSTER, H081_API_PORT), then ~/.h081/config.json, then the mainnet defaults |
| The data folder | The home option, then H081_HOME, then ~/.h081 |
Options
| Option | Default | What it does |
|---|---|---|
home | H081_HOME or ~/.h081 | The data folder. One per agent. |
env | process.env | The environment to read settings from. |
cluster | 'mainnet-beta' | 'mainnet-beta', or 'localnet' for tests. |
log | stderr | A function that gets each log line. Never a key, a token or a full RPC URL. |
maxSendsPerMin | H081_API_MAX_SENDS_PER_MIN or 30 | Confirmed sends allowed per minute. |
retryDelayMs | 1000 | The wait before the one automatic retry when the relayer is busy or rate-limited. |
pollSeconds | 20 | How often events re-read the balance while someone listens. |
import { H081 } from '@h081lll/agent';
// the defaults: ~/.h081 and process.env
const bank = await H081.open();
// a second agent with its own folder, quieter logs, and a lower send limit
const other = await H081.open({
home: '/home/you/agent-two',
log: () => {},
maxSendsPerMin: 5,
});Plan first, then confirm
Every method that moves money takes the same two options at the end, and returns an Outcome:
confirm- Leave it out and you get
status: 'plan': what would happen, with nothing proven, signed or sent. Passtrueto send. idempotencyKey- A name for this one send, so a retry never pays twice (see below). Default: a new random key.
const to = '<a wallet that never touched the agent>';
// 1. Without confirm: the plan. Nothing is proven, signed or sent.
const plan = await bank.withdraw('2', { to });
if (plan.status === 'plan') {
console.log(plan.result.fee, plan.result.payout, plan.result.warnings);
}
// 2. With confirm: true it is sent.
const sent = await bank.withdraw('2', { to, confirm: true, idempotencyKey: 'wd-2026-11-11-a' });
console.log(sent.status, sent.result); // 'sent', { signature, receivedUsdg, shieldedUsdg, ... }{
status: 'plan',
result: {
op: 'withdraw', usdg: '2', to: '<a wallet that never touched the agent>',
fee: '0.05', payout: '1.95', solNeeded: '0', circuit: 'transact', download: '15 MB',
publicOnChain: [ 'recipient', 'amount received' ], sentBy: 'relayer',
warnings: [ 'If <a wallet that never touched the agent> has no USDG account yet, the relayer adds … USDG to create it.' ],
next: 'Nothing was sent. Send it again with confirm: true (CLI: --yes) to do it.'
}
}status | When | result |
|---|---|---|
'plan' | No confirm: true | A Plan: op, usdg, to, fee, payout (what arrives), solNeeded, circuit, download, publicOnChain, sentBy ('relayer' or 'agent key'), warnings, details, next |
'sent' | It was sent | The chain result, with signature. Also idempotencyKey, and replayed: true when it was sent earlier with the same key. |
'done' | A read through a write method (lend('status'), stake('view')) | The answer |
Amounts are USDG as decimal strings: '5', '2.5', '0.25'.
Retry safely with idempotencyKey
A network can fail halfway. If you are not sure a payment went out, call again with the same key and the same arguments: if it went out, you get the first result back and nothing is sent twice. Records live in ~/.h081/ops.jsonl for 7 days and are shared with the command line and the local API on this computer.
const key = 'invoice-2026-11-0042'; // one key per real-world payment
const first = await bank.withdraw('2', { to, confirm: true, idempotencyKey: key });
// the process crashes, or you are not sure it went out: call again with the SAME key and the SAME arguments
const again = await bank.withdraw('2', { to, confirm: true, idempotencyKey: key });
console.log(again.status === 'sent' && again.replayed); // true: the first result, nothing sent twice| Same key again, and… | You get |
|---|---|
| the first call finished | Its result, with replayed: true. Nothing is sent. |
| the first call is still running | The error in_progress. Wait and call again. |
| different arguments | The error idempotency_key_reused. Use a new key for a new payment. |
| the process stopped mid-send | The error outcome_unknown: it may have gone out. Check balance({ fresh: true }) and history() before you try again with a new key. |
| it failed with a retryable error and nothing was sent | The key is free again: the same call may run with it. |
A key is 1 to 128 characters of A-Z a-z 0-9 _ . : -. Confirmed sends go one at a time through a queue; more than 8 waiting gives the error busy.
Reading: balance, history, fees
These never send anything and never need confirm.
address()
The agent's public wallet and its h081-address: code. Offline, not async.
const { wallet, addressCode } = bank.address();
console.log('Fund this wallet:', wallet);
console.log('Others pay you privately with:', addressCode);checkCode(code)
Checks any h081- code before you use it: { kind, network, valid, reason? }. Offline, not async.
const c = bank.checkCode('<a code someone sent you>');
if (!c.valid) console.log('Do not use it:', c.reason);
else console.log(c.kind, c.network); // 'address' 'mainnet-beta'status() and info()
status() says where things stand without touching the network. info() adds the protocol facts read from the chain.
console.log(bank.status());
// { network, agent, keySource, rpcHost, relayer, circuits: { present, missing }, queue: { depth }, fundingNeeded, ... }
console.log(await bank.info()); // protocol facts, deposit caps, relayer terms, SOL needed per usebalance(options?)
The private balance, the wallet's USDG and SOL, the deposit caps and every budget with its id. An answer up to 15 seconds old may be reused; fresh: true reads the chain now.
const b = await bank.balance({ fresh: true });
console.log(b.shielded + ' USDG private, ' + b.wallet.usdg + ' USDG and ' + b.wallet.sol + ' SOL in the wallet');
for (const a of b.allowances) console.log(a.id, a.remaining, a.holder, a.dailyCap, a.reclaimable);{
shielded: '3',
wallet: { usdg: '0', sol: '0.04' },
caps: { perDeposit: '1000', total: '10000', inVault: '…', paused: false },
allowances: [
{ id: '12345', remaining: '4.5', holder: 'sub-account 1', agentIndex: 1, dailyCap: '1', maxPerTx: '0.25',
expires: '2026-12-11T09:00:00.000Z', reclaimable: true, pledged: false }
],
addressCode: 'h081-address:…',
syncedAt: '2026-11-11T09:00:00.000Z'
}history(options?)
What this computer sent, newest first, from ~/.h081/ops.jsonl. limit is 1 to 200 (default 50); pass the returned next as after for the next page. Not async.
const page = bank.history({ limit: 20 }); // not async: it reads ~/.h081/ops.jsonl
for (const i of page.items) console.log(i.time, i.kind, i.usdg, i.state, i.signature ?? i.error);
if (page.next) console.log(bank.history({ after: page.next }).items.length, 'more');withdrawFee(usdg)
What a withdrawal would cost: fee, payout, feeBps, minFee, freeTierMax, needsStake, relayer and, when the relayer charges for a new USDG account, createAccountFee. No proof is made.
const q = await bank.withdrawFee('2');
console.log(q.fee, q.payout, q.needsStake); // '0.05' '1.95' falseMoving money
Each one returns the plan unless you pass confirm: true. Every example shows the plan first.
deposit(usdg, options?)
Wallet USDG into the private balance. Public: the chain shows the agent wallet and the amount.
console.log(await bank.deposit('5')); // the plan
await bank.deposit('5', { confirm: true, idempotencyKey: 'dep-1' });withdraw(usdg, { to, direct? })
Out of the bank to any wallet, sent by the relayer so the agent wallet is not shown as the sender. to is required: a wallet address, or 'self' for the agent wallet. Withdraw to a fresh wallet, never the one that deposited.
const to = '<a wallet that never touched the agent>';
console.log(await bank.withdraw('2', { to })); // the plan
await bank.withdraw('2', { to, confirm: true, idempotencyKey: 'wd-1' }); // through the relayer
// direct: true has the agent key send it: no fee, but the chain shows the agent wallet as the senderpay(usdg, { to | toAddressCode, allowanceId?, agentIndex?, direct?, allowPledged? })
Pays from a budget this agent holds. Give exactly one recipient: toAddressCode (private: amount and recipient hidden) or to (a wallet, public payout through the relayer). agentIndex pays from a budget your sub-account holds; allowanceId picks one budget; otherwise one whose limits allow the amount is picked. A budget pledged to a loan is used only with allowPledged: true.
const shop = '<their h081-address: code>';
console.log(await bank.pay('0.5', { toAddressCode: shop, agentIndex: 1 })); // the plan
await bank.pay('0.5', { toAddressCode: shop, agentIndex: 1, confirm: true, idempotencyKey: 'order-77' });
// a public payout to a wallet instead, from a budget picked by id: the plan first, then the same call with confirm: true
console.log(await bank.pay('0.5', { to: '<their wallet>', allowanceId: '12345' }));transfer(usdg, { toAddressCode })
From the main private balance to another H081 account's h081-address: code. Amount and recipient stay hidden.
const friend = '<their h081-address: code>';
console.log(await bank.transfer('1', { toAddressCode: friend })); // the plan
await bank.transfer('1', { toAddressCode: friend, confirm: true, idempotencyKey: 'tr-1' });allow(budget, { dailyCap?, maxPerTx?, days?, unlockDays?, agentIndex?, toAddressCode? })
Gives a budget to your sub-account agentIndex (default: the first free one) or to another agent by toAddressCode. The limits are checked on every payment. days ends it; unlockDays releases it bit by bit. The plan's details shows the exact rules. More on Allowances.
const rules = { dailyCap: '1', maxPerTx: '0.25', days: 30, agentIndex: 1 };
console.log(await bank.allow('5', rules)); // the plan, with the exact rules
await bank.allow('5', { ...rules, confirm: true, idempotencyKey: 'budget-research-1' });
// to another agent instead of a sub-account: the plan first, then the same call with confirm: true
console.log(await bank.allow('2', { toAddressCode: '<their h081-address: code>', days: 7 }));reclaim(allowanceId, options?)
Takes back what is left of a budget you gave. The id changes after every payment, so read balance() just before.
const b = await bank.balance({ fresh: true }); // ids change after every payment
for (const a of b.allowances.filter((x) => x.reclaimable)) {
console.log(await bank.reclaim(a.id)); // the plan: how much comes back
await bank.reclaim(a.id, { confirm: true });
}Loans and staking
Borrow against a budget, or lend to one. A lender can never receive more than the principal: anything paid beyond it goes back to the borrower's payout account. Step by step for both sides on Loans.
loanQuote(termsCode, { principal? }) and borrow(termsCode, { days, unlockDays, principal?, budget?, dailyCap?, maxPerTx?, agentIndex? })
The borrower's side. loanQuote prices a loan from the lender's h081-loan-terms: code and sends nothing. borrow pledges a budget and returns an h081-loan-proof: code for the lender. Nothing is borrowed until the lender funds it.
const terms = '<the lender h081-loan-terms: code>';
console.log(await bank.loanQuote(terms, { principal: '10' })); // most you can borrow, the 0.25% fee, the payout
const opts = { days: 30, unlockDays: 30, principal: '10' };
console.log(await bank.borrow(terms, opts)); // the plan
const done = await bank.borrow(terms, { ...opts, confirm: true }); // pledges a budget
console.log(done.result); // includes proofCode: the h081-loan-proof: code to hand to the lenderlend(action, fields, options?) and lendStatus(loanRef?)
The lender's side. action is 'open' (fields: pledgeBps, 100 to 9000), 'fund' (proofCode), 'claim' (loanRef, optional usdg), 'collect' or 'settle' (loanRef). The agent key sends every lender action, so the agent wallet is public as the lender.
console.log(await bank.lend('open', { pledgeBps: 2000 })); // the plan
const open = await bank.lend('open', { pledgeBps: 2000 }, { confirm: true }); // result: the terms code to hand out
console.log(await bank.lend('fund', { proofCode: '<the borrower h081-loan-proof: code>' })); // the plan
await bank.lend('fund', { proofCode: '<the borrower h081-loan-proof: code>' }, { confirm: true, idempotencyKey: 'fund-1' });
console.log(await bank.lendStatus('3')); // funded, repaid, owed, claimable
console.log(await bank.lend('claim', { loanRef: '3' })); // the plan
await bank.lend('claim', { loanRef: '3' }, { confirm: true }); // never more than the loan still owesstakeView() and stake(action, { h081? })
Stake H081 to withdraw more than 100 USDG at a time through the relayer. action is 'stake', 'request_unstake' (starts a 30-day wait) or 'unstake'.
console.log(await bank.stakeView());
console.log(await bank.stake('stake', { h081: '1000' })); // the plan
await bank.stake('stake', { h081: '1000', confirm: true });
await bank.stake('request_unstake', { h081: '1000', confirm: true }); // then wait 30 days
await bank.stake('unstake', { confirm: true });Live updates
bank.events.subscribe(fn) calls fn for each event and returns a function that stops it. While at least one listener is subscribed, the balance is re-read every pollSeconds, and changes arrive as balance.changed and allowance.changed. Sends made through this object arrive as op.completed and op.failed. Call close() when you are done so the program can exit.
const stop = bank.events.subscribe((e) => {
console.log(e.id, e.type, e.data); // balance.changed, allowance.changed, op.completed, op.failed
});
// ... later
stop();
bank.close();Run the local API from your own program
The same API that npx -y @h081lll/agent serve starts, inside your process. It listens on 127.0.0.1 only.
import { H081, readToken, startServer } from '@h081lll/agent';
const bank = await H081.open();
const token = readToken(bank.paths.token); // ~/.h081/api-token, written by init
if (!token) throw new Error('no API token yet: run h081 token once');
const srv = await startServer({ h081: bank, token });
console.log('local API on', srv.url); // http://127.0.0.1:8081/v1
// ... later
await srv.close();
bank.close();Errors
When something goes wrong, the method throws. Nothing was sent unless the error says it may have been (outcome_unknown). Pass any error to classify(e) to get the same stable fields the local API returns:
code- A stable name such as
needs_sol,insufficient_shielded,allowance_blockedorrelayer_busy. All of them are on Errors and fixes. retryabletruewhen the same call may simply be tried again later.message,details,status- A sentence you can show, extra facts (for example
details.needSol), and the HTTP status the API would use.
import { classify } from '@h081lll/agent';
try {
await bank.withdraw('2', { to, confirm: true, idempotencyKey: 'wd-7' });
} catch (e) {
const err = classify(e); // { code, status, retryable, message, details }
if (err.code === 'needs_sol') console.log('Send a little SOL to', bank.address().wallet);
else if (err.code === 'outcome_unknown') console.log('It may have gone out: check balance({ fresh: true }) before a new key');
else if (err.retryable) console.log('Nothing was sent; try again later with the same key:', err.message);
else throw e;
}Errors the SDK raises itself (a bad amount, a reused key, an invalid code) are CodedError objects with code and details already set. Errors from the chain or the relayer (for example allowance_blocked) are plain errors, so always use classify(e) to read the code. ERROR_CODES lists every code with its status and whether it is retryable.
import { classify, CodedError, ERROR_CODES } from '@h081lll/agent';
try {
await bank.pay('50', { toAddressCode: shop, agentIndex: 1, confirm: true });
} catch (e) {
if (e instanceof CodedError) console.log('refused by the SDK:', e.code, e.details); // e.g. invalid_code
const err = classify(e); // works for every error, CodedError or not
if (err.code === 'allowance_blocked') {
console.log(err.message); // names the limit that stopped it
console.log(ERROR_CODES[err.code].retryable); // false
}
}Complete example: a research agent with a daily budget
A small, real program. Your main agent gives a research agent (sub-account 1) a budget of 5 USDG for 30 days, at most 1 USDG a day and 0.25 USDG per purchase. The research agent then buys papers from a data provider, paying privately to its h081-address: code, until the daily cap stops it. Run as it is, it only prints plans; with SEND=1 it sends.
Save the script
Put the data provider's
h081-address:code inPROVIDER.research-agent.mts // research-agent.mts: a research agent with a daily budget. // It gets 5 USDG for 30 days, at most 1 USDG a day and 0.25 USDG per purchase, in sub-account 1, // and buys papers from a data provider privately, by its h081-address: code. // Run: npx tsx research-agent.mts (plans only) // SEND=1 npx tsx research-agent.mts (sends) import { H081, classify } from '@h081lll/agent'; const SEND = process.env.SEND === '1'; const PROVIDER = '<the data provider h081-address: code>'; const SUB = 1; const PRICE = '0.25'; const bank = await H081.open(); // 1. The budget, once. The fixed key means a second run never grants it twice. const rules = { dailyCap: '1', maxPerTx: PRICE, days: 30, agentIndex: SUB }; const has = (await bank.balance({ fresh: true })).allowances.some((a) => a.agentIndex === SUB && a.remaining !== '0'); if (!has) { const grant = await bank.allow('5', { ...rules, confirm: SEND, idempotencyKey: 'research-budget-2026-11' }); console.log(grant.status === 'plan' ? 'Budget plan (nothing sent):' : 'Budget granted:', grant.result); } // 2. Buy what the research needs until today's cap says stop. One key per paper per day: a re-run the same day // never pays twice, and a paper the cap refused today can be tried again tomorrow. const today = new Date().toISOString().slice(0, 10); // history() keeps 7 days: for a longer project, keep your own list of what was bought. const bought = new Set(bank.history({ limit: 200 }).items.filter((i) => i.state === 'done').map((i) => i.id.split(':')[0])); async function buy(paper: string): Promise<boolean> { if (bought.has('paper-' + paper)) return true; // paid on an earlier day try { const out = await bank.pay(PRICE, { toAddressCode: PROVIDER, agentIndex: SUB, confirm: SEND, idempotencyKey: 'paper-' + paper + ':' + today }); if (out.status === 'plan') { console.log(paper + ': would pay ' + PRICE + ' USDG (nothing sent)', out.result.warnings); return true; } const r = out.result as { signature: string; remainingUsdg?: string }; console.log(paper + ': paid' + (out.replayed ? ' earlier' : '') + ', ' + r.remainingUsdg + ' USDG left (' + r.signature + ')'); return true; } catch (e) { const err = classify(e); if (err.code === 'allowance_blocked') { console.log(paper + ': not today. ' + err.message); // the daily cap, the per-payment cap or the budget is used up return false; } throw e; } } for (const paper of ['2611-0042', '2611-0057', '2611-0101', '2611-0133', '2611-0150']) { if (!(await buy(paper))) break; } console.log('Private balance:', (await bank.balance({ fresh: true })).shielded, 'USDG'); bank.close();Run it without sending
any terminal npx tsx research-agent.mtsRead the plans it prints: the budget's rules, and each payment's warnings.
Run it for real
macOS / Linux SEND=1 npx tsx research-agent.mtsWindows PowerShell $env:SEND = "1"; npx tsx research-agent.mtswhat it prints (an illustration; your numbers differ) Budget granted: { sent: true, signature: '4tN…', agentIndex: 1, allowanceId: '…', rules: { … }, … } 2611-0042: paid, 4.75 USDG left (3pK…) 2611-0057: paid, 4.5 USDG left (5Wq…) 2611-0101: paid, 4.25 USDG left (2Bd…) 2611-0133: paid, 4 USDG left (67m…) 2611-0150: not today. … (the daily cap) Private balance: 0 USDGRun it again tomorrow: the budget is not granted twice (it checks the balance first, and the grant has a fixed key), papers already bought are skipped (it reads
history(), which keeps 7 days), and the daily cap allows 1 USDG more.
When the project ends, take back what is left:
// When the project ends: take back what is left of the research budget.
const b = await bank.balance({ fresh: true });
for (const a of b.allowances.filter((x) => x.agentIndex === SUB && x.reclaimable)) {
console.log(await bank.reclaim(a.id)); // the plan
await bank.reclaim(a.id, { confirm: true, idempotencyKey: 'research-reclaim-' + a.id });
}The same call from other tools
The SDK, the command line and the local API share one engine, one key and the same errors. Here is the same withdrawal plan four ways; none of them sends anything.
TOKEN=$(cat ~/.h081/api-token)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"usdg":"2","to":"<fresh wallet>"}' http://127.0.0.1:8081/v1/withdraw$h = @{ Authorization = "Bearer $(Get-Content $HOME\.h081\api-token)" }
Invoke-RestMethod -Method Post -Headers $h -ContentType application/json -Body '{"usdg":"2","to":"<fresh wallet>"}' http://127.0.0.1:8081/v1/withdrawimport pathlib, requests
token = (pathlib.Path.home() / ".h081" / "api-token").read_text().strip()
r = requests.post("http://127.0.0.1:8081/v1/withdraw",
headers={"Authorization": "Bearer " + token},
json={"usdg": "2", "to": "<fresh wallet>"})
print(r.json()["result"]) # the plan: nothing is sentimport { H081 } from '@h081lll/agent';
const bank = await H081.open();
console.log((await bank.withdraw('2', { to: '<fresh wallet>' })).result); // the plan: nothing is sentNext
- BudgetsAllowancesLimits, sub-accounts and taking a budget back.
- AdvancedLoansBorrow against a budget, or lend to one.
- SafetyPrivacy and safetyWhat the chain sees and how to keep the key safe.
- HelpErrors and fixesEvery error code and what to do.
New here? The 5-minute quickstart does a first private payment end to end. The package is @h081lll/agent on npm. Questions: @H081CAP on X.