In plain words
When H081 cannot do something, it tells you why with a short name, like needs_sol (the agent needs a little more SOL for fees). Each name has a plain sentence and a yes-or-no answer to "is it safe to just try again?".
Most errors happen before anything is sent, so no money moved. The one to take seriously is outcome_unknown: then check your balance before you do anything else.
How to read an error
The local API, the command line with --json, and the SDK all describe an error the same way:
{ "ok": false, "network": "mainnet-beta",
"error": { "code": "needs_sol",
"message": "the agent wallet holds 0.0050 SOL; a deposit needs about 0.02 SOL for buffer rent (refunded) and fees.",
"retryable": false,
"details": { "haveSol": "0.0050", "needSol": "0.02" } },
"requestId": "req_..." }okfalsefor an error,truefor success.error.code- A short, fixed name. Programs should check this, never the message.
error.message- A sentence for people: what happened and, usually, what to do.
error.retryabletrue: nothing was sent and the same request may be tried again later, with the same idempotency key.false: something must change first. Then send it with a new idempotency key: the old key keeps returning this same error.error.details- Extra facts when there are some, like
needSol, the relayer's reason, or the idempotency key. requestId- The ID of this call in the local server's log. The command line does not print one.
The API also sets the HTTP status (401, 409, 503…) and, for waits, a Retry-After header in seconds.
# a wrong token on purpose: -i shows the HTTP status too
curl -s -i -H "Authorization: Bearer wrong" http://127.0.0.1:8081/v1/balance# Invoke-RestMethod throws on an error; the body is in ErrorDetails
try {
Invoke-RestMethod -Headers @{ Authorization = "Bearer wrong" } http://127.0.0.1:8081/v1/balance
} catch {
$e = ($_.ErrorDetails.Message | ConvertFrom-Json).error
"$($e.code) (retryable: $($e.retryable)): $($e.message)"
}import pathlib, requests
API = "http://127.0.0.1:8081/v1"
token = (pathlib.Path.home() / ".h081" / "api-token").read_text().strip()
r = requests.get(API + "/balance", headers={"Authorization": "Bearer " + token})
data = r.json()
if data["ok"]:
print(data["result"]["shielded"])
else:
err = data["error"]
print(r.status_code, err["code"], err["retryable"], err["message"])
print(err.get("details"))import { H081, classify } from '@h081lll/agent';
const bank = await H081.open();
console.log((await bank.deposit('5')).result); // the plan: nothing is sent
try {
await bank.deposit('5', { confirm: true, idempotencyKey: 'dep-1' });
} catch (e) {
const err = classify(e); // { code, status, retryable, message, details }
console.log(err.code, err.status, err.retryable, err.message, err.details);
}HTTP/1.1 401 Unauthorized
content-type: application/json; charset=utf-8
...
{"ok":false,"network":"mainnet-beta","error":{"code":"unauthorized","message":"missing or wrong token: send Authorization: Bearer <the token in ~/.h081/api-token>","retryable":false},"requestId":"req_..."}- You
Deposit 5 USDG into my H081 account.
- Claude
uses
h081_bank_depositI could not deposit: your agent wallet holds 0.0050 SOL, and a deposit needs about 0.02 SOL for network fees and a small refundable deposit. Nothing was sent.
Send a little SOL to your agent address and ask me again.
With Claude, a refused action comes back as a tool result with the same code and message, so Claude can explain it and change course.
Every error code
Grouped by what kind of problem it is. "Retry?" says whether the same request may simply be sent again later. On phones, the tables scroll sideways.
Your request
Something about the call itself: the token, the address, the format.
| Code | HTTP | Retry? | What it means | What to do |
|---|---|---|---|---|
unauthorized | 401 | No | The token is missing or wrong. | Send Authorization: Bearer <token>. The token is in ~/.h081/api-token; the token command prints it. After token --rotate, update your scripts. |
forbidden_origin | 403 | No | The call came from a web page, or used an address other than 127.0.0.1 or localhost. | Call http://127.0.0.1:8081 or http://localhost:8081. For one web page, start serve with --allow-origin <its exact URL>. |
not_found | 404 | No | No such route, or the wrong method (GET instead of POST). | Check the path and method. GET /v1/openapi.json lists every route. |
bad_request | 400 | No | A field is missing or wrong. The message names it. | Fix that field. Amounts are strings like "2.5". On the command line, a usage mistake (unknown command, missing option) also shows as bad_request, with exit code 2. |
unsupported_media_type | 415 | No | A POST body was not sent as JSON. | Add the header Content-Type: application/json. |
invalid_code | 400 | No | An h081-… code failed its checksum, kind or network check: a typo, a cut-off copy, or the wrong kind of code. | Copy the whole code again. Check it with the check command or POST /v1/codes/check. |
Retries and idempotency keys
The safety net that stops a payment from going out twice.
| Code | HTTP | Retry? | What it means | What to do |
|---|---|---|---|---|
idempotency_key_required | 400 | No | A send with "confirm": true has no Idempotency-Key header, or the key has characters that are not allowed. | Add Idempotency-Key: 1 to 128 of A-Z a-z 0-9 _ . : - |
idempotency_key_reused | 422 | No | This key was already used for a different request. | Use a new key for every new payment. |
in_progress | 409 | Yes | A request with this key is still running. | Wait 5 seconds (Retry-After) and send the same request with the same key. |
outcome_unknown | 409 | No | It may or may not have been sent: the server stopped, or the connection broke mid-send. | Do not resend. Check the balance (balance --fresh, GET /v1/balance?fresh=1) first. Only if nothing went out, send again with a new key. |
Money, fees and limits
Nothing was sent. Something needs topping up, or the amount is over a limit.
| Code | HTTP | Retry? | What it means | What to do |
|---|---|---|---|---|
insufficient_shielded | 409 | No | Your private balance is too low. | Deposit more, or ask for less. Check with balance. |
insufficient_wallet_usdg | 409 | No | The agent wallet holds too little public USDG, for example for a deposit. | Send USDG to the agent address (the address command prints it). |
needs_sol | 409 | No | The agent wallet needs more SOL for network fees. details.needSol says how much. | Send SOL to the agent address. See "Not enough SOL" below. |
cap_per_deposit | 409 | No | Over the per-deposit cap (1,000 USDG on mainnet). | Split it into smaller deposits. |
cap_total | 409 | No | The bank is at its total cap (10,000 USDG on mainnet). | Deposit less, or later. |
deposits_paused | 409 | No | New deposits are paused. Withdrawals still work. | Try the deposit later. |
over_operator_ceiling | 409 | No | Over a ceiling set on this computer (H081_MAX_DEPOSIT, H081_MAX_PAY, H081_MAX_GRANT or H081_MAX_LEND). | Ask for less, or raise that setting (in base units: 1 USDG = 1000000). |
needs_stake | 409 | No | Above what the relayer sends with nothing staked (100 USDG per withdrawal). | Split the amount, stake H081, or use --direct (your agent wallet becomes public as the sender). |
allowance_blocked | 409 | No | No budget can pay this amount now. The message names the limit that blocks it: per payment, per day (window), expiry, what is left, or the part not unlocked yet. | Pay less, wait for the window to reset or more of the budget to unlock, or ask the owner for a new budget. balance lists your budgets and their limits. |
Network, relayer and timing
Usually brief. Most are safe to retry with the same key.
| Code | HTTP | Retry? | What it means | What to do |
|---|---|---|---|---|
relayer_unavailable | 503 | Yes | The relayer cannot be reached, or refused. Nothing was sent. details says why. | Retry later with the same key. Only if you accept a public sender, use --direct. |
relayer_busy | 503 | Yes | The relayer is busy. Nothing was sent. It was already retried once for you. | Wait a few seconds, then retry with the same key. |
rate_limited | 429 | Yes | Too many requests: the relayer said so, or this computer passed 30 sends a minute (H081_API_MAX_SENDS_PER_MIN). | Wait (the Retry-After header says how long when it is set; otherwise a few seconds), then retry with the same key. |
busy | 503 | Yes | The local queue is full (8 sends are already waiting). | Wait 5 seconds (Retry-After) and retry. |
stale_root | 409 | Yes | The bank moved on while the proof was being made (a newer Merkle root or timestamp). | Retry with the same key. See "Stale root" below. |
in_flight | 409 | Yes | Some of your money in this operation is already being spent by another operation. | Let the other one finish, then retry. |
rpc_unreachable | 502 | Yes | The Solana RPC cannot be reached. | Check the URL, or set your own with init --rpc or H081_RPC_URL. |
On-chain refusals
A Solana program said no. The message explains which rule.
| Code | HTTP | Retry? | What it means | What to do |
|---|---|---|---|---|
already_spent | 409 | No | Money in this operation was already spent, often by another operation from the same account at the same time. | Check balance --fresh, then make a new request. |
simulation_failed | 422 | No | The transaction failed a dry run. Nothing was sent. | Read the message; it says what the program objected to. |
program_error | 422 | No | An on-chain program refused it. When known, details names the program and its error. | Read the message: it names the rule and, for the common ones, what to change. |
Setup
Something on this computer is missing.
| Code | HTTP | Retry? | What it means | What to do |
|---|---|---|---|---|
circuits_missing | 503 | No | The proof files are not downloaded yet. | Run fetch-circuits. See "Proof files missing" below. |
circuit_hash_mismatch | 503 | No | A downloaded proof file does not match its pinned fingerprint: damaged, or not from H081. | Delete ~/.h081/circuits and run fetch-circuits again. |
no_key | 500 | No | There is no agent key. | Run init, or restore from your backup code. |
not_available | 501 | No | Not available in this version. | This version cannot do it. Check for a newer version of @h081lll/agent. |
internal | 500 | No | Something unexpected. | Check the balance before trying a send again. Keep the requestId; the server log names it. |
Common situations
Not enough SOL (needs_sol)
SOL is Solana's own coin. Every action pays a small network fee in SOL, and some stage a proof on chain first, which needs a refundable deposit. The error tells you what the wallet has (details.haveSol) and roughly what it needs (details.needSol).
| To use | Keep in the agent wallet |
|---|---|
| Deposit and withdraw | 0.05 SOL |
| Budgets (allowances) or lending | 0.15 SOL |
Find the agent's address
any terminal npx -y @h081lll/agent addressSend SOL to it
From any Solana wallet or exchange. Send a little more than
needSol.Check it arrived, then see the plan again
any terminal npx -y @h081lll/agent balance --freshthe plan (nothing is sent); add --yes when it looks right npx -y @h081lll/agent deposit 5From the API or the SDK, send it with a new idempotency key. The key you used before keeps returning
needs_sol. (The command line makes a new key on every run.)
Relayer busy or unavailable
The relayer sends withdrawals for you. When it is busy (relayer_busy) or answers "too many requests" (rate_limited), H081 already waited and tried once more before giving you the error. Nothing was sent.
{ "ok": false, "network": "mainnet-beta",
"error": { "code": "relayer_busy", "message": "...", "retryable": true,
"details": { "relayerStatus": 503, "reason": "busy" } },
"requestId": "req_..." }Wait a few seconds
Use
Retry-Afterwhen the response has it.Send the same request with the same idempotency key
The key was released because nothing was sent, so it runs again. If it had gone through, you would get the stored answer instead: it can never pay twice.
command line: the plan first (nothing is sent) npx -y @h081lll/agent withdraw 2 --to <a wallet that never touched the agent>then send, passing the key yourself so a rerun is the same request npx -y @h081lll/agent withdraw 2 --to <a wallet that never touched the agent> --yes --idempotency-key wd-7Still down after a while?
If the relayer stays unreachable (
relayer_unavailable), wait and try later.--directskips the relayer, but then your agent wallet is public as the sender.
A retry loop that is safe to copy: it retries only when retryable is true, keeps the same key, and stops at outcome_unknown.
import pathlib, time, uuid, requests
API = "http://127.0.0.1:8081/v1"
s = requests.Session()
s.headers["Authorization"] = "Bearer " + (pathlib.Path.home() / ".h081" / "api-token").read_text().strip()
def send(path, body, key, tries=3):
"""A confirmed send that retries only when the API says it is safe."""
for attempt in range(tries):
r = s.post(API + path, json={**body, "confirm": True}, headers={"Idempotency-Key": key})
data = r.json()
if data["ok"]:
return data["result"]
err = data["error"]
if err["code"] == "outcome_unknown":
raise RuntimeError("It may have been sent. Check GET /v1/balance?fresh=1 before anything else.")
if not err["retryable"] or attempt == tries - 1:
raise RuntimeError(err["code"] + ": " + err["message"])
time.sleep(int(r.headers.get("Retry-After", "5"))) # same key: it can never send twice
body = {"usdg": "2", "to": "<fresh wallet>"}
print(s.post(API + "/withdraw", json=body).json()["result"]) # the plan: no "confirm", nothing is sent
key = str(uuid.uuid4()) # one key per payment
print(send("/withdraw", body, key))import { H081, classify } from '@h081lll/agent';
const bank = await H081.open();
const to = '<fresh wallet>';
console.log((await bank.withdraw('2', { to })).result); // the plan: nothing is sent
const key = crypto.randomUUID(); // one key per payment
for (let attempt = 1; ; attempt++) {
try {
console.log(await bank.withdraw('2', { to, confirm: true, idempotencyKey: key }));
break;
} catch (e) {
const err = classify(e);
if (err.code === 'outcome_unknown') { console.log('Maybe sent. Check first:', await bank.balance({ fresh: true })); break; }
if (!err.retryable || attempt === 3) throw e;
await new Promise((r) => setTimeout(r, 5000)); // same key: it can never send twice
}
}Outcome unknown (outcome_unknown)
It happens when the local server stopped in the middle of a send, or the connection to the relayer or Solana broke while sending. It is never marked retryable, and the same key keeps giving this answer, so a script cannot pay twice by accident.
Check the balance and recent history
any terminal npx -y @h081lll/agent balance --fresh npx -y @h081lll/agent history --limit 5From the API:
GET /v1/balance?fresh=1andGET /v1/history.Decide
Balance dropped, or the money arrived: it went through. Do nothing. Balance unchanged and nothing arrived: send it again with a new idempotency key.
Stale root (stale_root)
A proof is made against a snapshot of the bank (its "Merkle root" and a timestamp). If other people used the bank while your proof was being made, the snapshot is out of date and the program refuses it. Nothing was sent.
{ "ok": false, "network": "mainnet-beta",
"error": { "code": "stale_root", "message": "...", "retryable": true, "details": { ... } },
"requestId": "req_..." }Fix: send the same request again; the same idempotency key is fine. A new proof is made against the current snapshot. in_flight is similar: another operation from your account is spending the same money right now, so let it finish first.
Proof files missing or damaged
Proofs need proving files (about 98 MB in total), downloaded once into ~/.h081/circuits and checked against pinned fingerprints. serve downloads them in the background; the first proof also fetches what it needs.
circuits_missing: download them.
npx -y @h081lll/agent fetch-circuitscircuit_hash_mismatch: a file is damaged or not from H081, and it is refused. Delete the folder and download again. If it happens again, tell us at @H081CAP.
rm -rf ~/.h081/circuits
npx -y @h081lll/agent fetch-circuitsRemove-Item -Recurse -Force $HOME\.h081\circuits
npx -y @h081lll/agent fetch-circuitsCannot reach Solana (rpc_unreachable) or "429 Too Many Requests"
The RPC is the Solana server your computer talks to. The public one limits how often you can ask. Use your own RPC URL (a free tier is enough):
npx -y @h081lll/agent init --rpc <your Solana RPC URL>Or set H081_RPC_URL for one program. Only the RPC's host name is ever shown in messages and logs.
Command line exit codes
Every command ends with a number that scripts can check:
| Exit code | Meaning |
|---|---|
0 | It worked (or it printed a plan). |
1 | An error. With --json, the error envelope is on stdout. |
2 | Bad usage: an unknown command or a missing or wrong option. With --json, stdout has a bad_request envelope. |
With --json, stdout (the command's normal output) carries only the JSON envelope; messages and logs go to stderr (its separate error output), so you can pipe stdout into another program.
npx -y @h081lll/agent deposit 5 --yes --json{
"ok": false,
"network": "mainnet-beta",
"error": {
"code": "needs_sol",
"message": "the agent wallet holds 0.0050 SOL; a deposit needs about 0.02 SOL for buffer rent (refunded) and fees.",
"retryable": false,
"details": { "haveSol": "0.0050", "needSol": "0.02" }
}
}npx -y @h081lll/agent balance --json > balance.json
echo "exit code: $?"npx -y @h081lll/agent balance --json > balance.json
"exit code: $LASTEXITCODE"- RelatedPrivacy and safetyWhat is public, your key, ceilings and safe retries.
- ReferenceLocal APIEvery route, with examples.
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.