Operating your agent
Your agent will do OK out of the box. To move funds well, give it a policy - what to shield, when to send, when to hold. Concrete instructions beat abstract ones every time.
How instructions actually flow
Your agent reads three layers when it works with RAIL20:
- The RAIL20 CLI (or raw HTTP API) - what commands exist, how signatures work, how the burner flow runs. Reference-level, not policy.
- Any agent skill or framework you're using (LangChain tool, custom function, cron script) - the loop and glue.
- Your conversation with the agent - the policy. What amounts, what recipients, when to move, when to stop.
Layer 3 is the highest-priority layer. The CLI is reference; you set the policy. If you tell your agent "never send more than 10 USDC per hour to any single address," that overrides any neutral defaults it might otherwise adopt.
Set a policy before it moves funds
The most useful thing you can do is hand your agent a one-paragraph policy before it does anything on-chain. Be operational - not "be careful," but exactly what "careful" means in specific spots.
Example - private treasury manager
You manage a private treasury on RAIL20 (Base). Rules: - Shield deposits within 10 minutes of arrival at the public wallet. - Keep at least 5 USDC unshielded for emergency gas / manual overrides. - Never send more than 25 USDC in a single tx or 100 USDC per rolling 24 hours. - Any send > 10 USDC to a new recipient (not seen in the last 30 days) requires my explicit confirmation. - Log every send to a local audit file; include tx hash, recipient, amount, timestamp. - If a swap or bridge fails, run `rail20 recover` immediately and report the sweep back to me. - Never touch the ETH balance below 0.01 (that's my gas floor).
Example - A2A payment agent
You pay two peer agents privately for services rendered: - Payments are triggered by invoices from Peer A (0xA1..) or Peer B (0xB2..). No other recipients. - Amount must match invoice within ±1%. If it doesn't, decline and alert me. - Bundle daily invoices into one send per peer per day where possible (fee is flat 1 USDC + 0.35%, and it is charged per send, so batching saves money). - Use `rail20 send` from the USDC pool. Never use the ETH pool. - If private USDC balance drops below 5, shield 20 USDC from the public wallet before continuing. - Never expose the recipient list, invoice contents, or amounts in any log that could leak publicly.
Example - cross-chain rebalancer
You keep private balances split across Base and Robinhood, and bridge out to Arbitrum/BNB/Ethereum on demand: - Target split of total private balance: 60% Base, 40% Robinhood. Rebalance only if either drifts > 10% from target. - Base <-> Robinhood uses the direct router: `rail20 bridge rh_usdg <addr> <amt> --from usdc --chain base` (or `rbase_usdc ... --chain robinhood` for the reverse). - Bridges out to Arbitrum/BNB/Ethereum use 1Click: `rail20 bridge arb_usdc <addr> <amt>`. - Minimum bridge is 2 USDC (fee is flat 1 USDC + 0.35% - anything smaller loses too much). Never bridge more than 50 USDC in one tx; split if needed. - After a bridge shows SUCCESS, wait 5 minutes before the next to avoid solver capacity issues. - If any bridge shows REFUNDED, alert me and stop rebalancing until I clear.
Drop-in system prompt
Paste this as your agent's system prompt (or into a rail20-policy.txt it reads on start). It teaches the agent the RAIL20 model, the exact commands, the failure modes, and the safety rails in one block. Fill in the bracketed policy values, then layer any of the example policies above on top.
# ROLE You operate a private on-chain balance for me using RAIL20, a zero-knowledge privacy layer live on Base and Robinhood Chain. You move funds on my behalf. You are careful, literal, and you never improvise around the rules below. # HOW RAIL20 WORKS (so you reason correctly) - I sign one fixed message once; that derives my private account. Never ask for or log my private key. Only the derived signature is used, and the relayer pays gas. - Funds live as private notes in a per-token pool: ETH + USDC on Base, ETH + USDG on Robinhood. Deposits (shield) are free; sends/swaps/bridges pay a flat fee + 0.35%. - On-chain, every operation looks like an unlinkable commitment + nullifier. Balances are reconstructed from notes, so they lag a few seconds after any tx. # TOOLS (RAIL20 CLI - one command per action) rail20 balance [--pool eth|usdc|usdg] [--chain base|robinhood|all] [--wait] rail20 deposit <amt> --pool <pool> [--chain ...] # shield public -> private rail20 send <to> <amt> [--pool ...] [--chain ...] # private transfer, no gas from me rail20 swap <amt> --from eth|usdc|usdg [--chain ...] # same-chain private swap rail20 bridge <dest> <to> <amt> [--from ...] [--chain ...] # Base<->Robinhood: dest rh_eth|rh_usdg|rbase_eth|rbase_usdc (router) # Arbitrum/BNB/Ethereum: dest arb_usdc|bsc_usdt|eth_usdc|... (1Click) rail20 recover [--chain base|robinhood|all] # sweep stranded burner funds (idempotent) rail20 assets # list bridge destinations # OPERATING RULES (non-negotiable) 1. Read balance with `--wait` after any tx that changes it before acting on the new number. 2. Minimum send/swap/bridge is 2 units on the stablecoin pools and 0.001 ETH (or BNB) on the native pools. Never attempt dust. 3. If a swap or bridge errors, IMMEDIATELY run `rail20 recover --chain <that chain>` and report the sweep tx. Funds on a failed flow sit on a recoverable burner - never leave them. 4. A bridge is only done when status is SUCCESS. REFUNDED/FAILED means funds returned to my private balance - report it and stop, do not retry blindly. 5. Never send my private key anywhere. Never paste balances, recipients, or amounts into a public/log surface that could leak. 6. If Robinhood commands fail with "could not detect network", "missing response", or show a "?" balance while Base works fine, the public RH RPC is unreachable from this network. Set `RAIL20_ROBINHOOD_RPC` to a reachable Robinhood Chain (4663) RPC (e.g. a private Alchemy endpoint) and retry - do NOT keep hammering the same failing command. Report the switch to me. # MY POLICY (edit these) - Max per tx: [25] USDC. Max per rolling 24h: [100] USDC. - Allowed recipients: [list, or "invoice-driven only"]. New recipient > [10] USDC needs my OK. - Keep >= [0.01] ETH unshielded as a gas floor. Keep >= [5] USDC private as working reserve. - Report a heartbeat every [30] min: balances (both pools, both chains), last txs, anything pending. # WHEN UNSURE Stop and ask me. A missed opportunity is cheap; an unauthorized or malformed transfer is not.
Why isn't my agent doing what I asked?
Three common reasons, in order of frequency:
- Your instruction was too abstract. "Be conservative" doesn't mean anything to the model. Be operational: "shield within 10 minutes of arrival," "never send > 25 USDC per tx." Give it thresholds, amounts, time windows.
- The session restarted. If your agent runs in a cron or a fresh chat, it forgets the policy. Either paste it back every session, or save it to a policy file the agent reads on start (e.g.
~/.agent/rail20-policy.txt). - The model reverted to defaults. After many turns or a summarization, the policy can drift. Re-state it concretely, and include 2-3 examples of what to do in specific spots ("if a swap fails at step 5, run
rail20 recoverand report the sweep tx").
Why did my transaction fail?
Four failure modes cover almost every fund-movement problem on RAIL20. Each has a specific fix.
1. Fee guard rejection - "withdrawal amount too small"
✗ Error: withdrawal amount must exceed 2× the fee
The relayer refuses sends where the fee is at least half the amount. Fee is flat + 0.35%. On top of that the app enforces a floor of 0.001 ETH (or BNB) and 2 units on the stablecoin pools for every send, swap and bridge. The ETH flat fee is 0.00015, so a 0.001 ETH send already pays ~15% - below that the ratio climbs fast, which is what the floor is there to prevent. This is by design: relayers can't profitably front proofs for dust.
Fix: increase the amount, or batch multiple small payments into one send.
2. Indexer lag - "balance shows 0 right after deposit"
private balance 0.0 USDC (moments after a successful deposit tx)
Private balances are reconstructed from on-chain notes. After a deposit or send tx, the indexer needs ~5-15 seconds to pick up the new commitment and expose it via /api/balance. During that window, balance can transiently look stale.
Fix: use rail20 balance --pool usdc --wait. It polls the indexer for up to 30 seconds until the balance is non-zero. In your policy, always use --wait after a tx that changes balance.
3. Stranded burner - "swap failed mid-flow"
✗ Error at step 5 of swap. Funds are safe on burner 0xF5d0..…
Swap runs a 6-step burner flow. If any step past the private withdraw fails (network glitch, gas spike, RPC hiccup), funds sit on a burner wallet. Each swap uses a fresh random burner whose key is encrypted and registered before funding, so it can always be recovered from any device that re-signs (older builds derived the burner at nonce 0-19 - those are still recoverable too).
Fix: run rail20 recover. It sweeps every registered burner (plus the legacy 0-19 slots) - any USDC + non-dust ETH goes back to your public wallet. Fully idempotent - safe to run any time. In your policy, always run recover after any swap error.
4. Insufficient private balance
✗ Error: withdraw exceeds available notes (asked N, have M)
Private balance is a set of notes, not a single number. If your agent asks to send more than the sum of unspent notes, the proof fails to build.
Fix: run rail20 balance before sending, and either shrink the amount or shield more via rail20 deposit. In your policy, always check balance before initiating a send if the amount could plausibly exceed reserves.
Talking to your agent during work
Once your agent is operating, keep nudging it:
- "Show me a heartbeat" - status snapshot: private/public balance both pools, last N txs, any pending swaps or bridges.
- "Pause all sends until I clear" - operational safety brake without shutting down the agent.
- "Why did you skip that invoice?" - agent explains its reasoning against your policy.
- "Reduce your daily cap to 20 USDC" - policy update mid-session.
- "Any stranded burners?" - triggers a
rail20 recoverdry check.
Hard limits
Some things the CLI and relayer won't do, no matter how you prompt:
- No private key exfiltration. The CLI never transmits your key. Only the derived signature is sent to the relayer. Anyone claiming otherwise is looking at the wrong code path.
- No sub-fee-guard sends. The relayer rejects amounts below
2 × fee. Your agent cannot bypass this by any prompt. - No cross-signature account access. The signature derives one account. Different key = different account = different balance. There is no admin override.
- No proof forgery. Only nullifiers spent by the note owner's key clear. The relayer builds the proof but cannot spend on the user's behalf.
Debugging your agent
When something's off, check these first.
Agent isn't executing any commands
Two common causes. (1) The session died - cron-style runners can stall silently. Restart it, or ask the agent to print a heartbeat every N minutes. (2) It's waiting on your confirmation for a large tx that hit the policy threshold. Ask "any pending confirmations?" - it should list them.
"Transaction reverted" or unexplained on-chain failure
Usually one of three things:
- Allowance propagation lag. On load-balanced public RPCs, an
approvetx can be confirmed while a fresheth_callto the same RPC still sees the old allowance. The CLI polls for allowance propagation before broadcasting the follow-up tx. If you're doing this manually, add a similar poll. - Chain-realistic gas. Base runs at ~0.001-0.05 gwei, Robinhood at ~0.047 gwei (its RPC may suggest ~1.6 gwei, ~34x too high). Ethers defaults to 1.5 gwei, which drains small burner top-ups before the tx completes. The CLI caps
maxFeePerGasper chain (0.05 gwei Base / 0.1 gwei Robinhood). Manual integrations should too. - Reshield gas underestimate. The reshield
transact()verifies a Groth16 proof on-chain and uses ~1.35M gas. If you cap gasLimit too low, it reverts near completion. The CLI usesestimateGas+ 20% buffer.
Agent lost a burner's private key
Normally recoverable. Each swap/bridge burner is a fresh random wallet whose key is encrypted (AES-256-GCM under your sign-in signature) and pushed to a recovery registry before it is funded. Run rail20 recover from any machine that has your key - it re-signs, fetches the registry, decrypts each burner key locally, and sweeps the funds. Legacy burners (derived at nonce 0-19 in older versions: keccak256("rail20-burner-v2:" + keccak256(signature) + ":" + nonce)) are still scanned and swept too.
One edge case: if the registry write failed at creation (rare - it is best-effort so it never blocks a swap) and the burner is random, its key exists only in that run's memory. If funds were then stranded on it, they are not recoverable. This is why the registry write happens before funding; a registry-register failure is logged, so watch for it.
"rail20 latest says I'm on an old version"
Update: npm install -g @rail20/cli@latest. Newer versions may include gas fixes, indexer improvements, or new commands that your agent's policy assumes.