DocsFor AI agents
Agents have CPUs, not ETH.
An agent can create a fresh address, receive tokens there and send them on, with every gas fee paid by the Unlinked
paymaster in exchange for a proof of work its own CPU mines. Nobody funds the address, so no funding transaction links it
back to the operator's wallet. @unlinked/agent packages this as an MCP server and a CLI.
Why it fits agents#
A wallet-holding agent normally needs ETH before it can do anything, and topping it up from the operator's wallet writes a public link between the two. Unlinked removes that step: the paymaster pays for an operation of a fresh EIP-7702 address when it carries a valid proof of work (8 keccak sub-solutions, seconds of CPU on a laptop), co-signed by the relay. The agent's address never holds ETH for gas.
What stays visible is everything else. The tokens an address receives link their sender to it, the relay sees the IP address and the time of each send, and every operation is public on chain. The limits are written into the server's instructions and into the descriptions of the tools that lead to a send, so the model reads them too.
Install#
# From a checkout of the Unlinked monorepo (Node 22+, pnpm)
$ cd /path/to/unlinked
$ pnpm install
$ pnpm --filter @unlinked/miner build # the Node miner loads its WASM build
$ node packages/agent/bin/unlinked.mjs --helpKeys are generated on the machine that runs the agent and stored encrypted in a local keystore: ~/.unlinked (or
UNLINKED_HOME), directories 0700 and files 0600, sealed with AES-256-GCM under a scrypt key derived from
UNLINKED_PASSPHRASE. The MCP server has no tool that returns a key; only the CLI's account export prints one, after an
explicit flag and a confirmation typed on a terminal.
Connect an MCP client#
The server speaks MCP over stdio (unlinked mcp). The network is never chosen by the agent: set UNLINKED_NETWORK to
mainnet or local in the client's configuration.
$ claude mcp add --transport stdio --scope user \
--env UNLINKED_NETWORK=mainnet \
--env UNLINKED_PASSPHRASE="$UNLINKED_PASSPHRASE" \
--env UNLINKED_MAX_AMOUNT='USDG=50,ETH=0.01' \
unlinked -- node /path/to/unlinked/packages/agent/bin/unlinked.mjs mcp{
"mcpServers": {
"unlinked": {
"command": "node",
"args": [
"/path/to/unlinked/packages/agent/bin/unlinked.mjs",
"mcp"
],
"env": {
"UNLINKED_NETWORK": "mainnet",
"UNLINKED_PASSPHRASE": "a-long-passphrase-only-this-config-knows",
"UNLINKED_MAX_AMOUNT": "USDG=50,ETH=0.01"
}
}
}
}{
"mcpServers": {
"unlinked": {
"command": "node",
"args": [
"/path/to/unlinked/packages/agent/bin/unlinked.mjs",
"mcp"
],
"env": {
"UNLINKED_NETWORK": "mainnet",
"UNLINKED_PASSPHRASE": "${UNLINKED_PASSPHRASE}",
"UNLINKED_MAX_AMOUNT": "USDG=50,ETH=0.01"
}
}
}
}A typical session: unlinked_network → unlinked_create_account → (the account receives assets) → unlinked_quote_send →
unlinked_send → unlinked_get_operation. A send always needs a fresh quote: the quote is the dry run (amount, guardrails,
recipient, balance, gas sponsored, work and time), and unlinked_send executes exactly what it describes, once, within
about 120 seconds. While it mines and waits for inclusion, the server sends notifications/progress to clients
that ask for them, and a cancelled call stops mining.
Tools#
| Tool | What it does | Annotations | CLI equivalent |
|---|---|---|---|
| unlinked_network | Chain, relay, paymaster, current epoch, difficulty, budget, guardrails and honest limits. | read-only, network | unlinked network |
| unlinked_create_account | Generates a key on this machine, stores it encrypted, returns the address only. Parameters: label (optional). | additive, local only | unlinked account new [--label NAME] |
| unlinked_list_accounts | Addresses in the local keystore for this network. Reads nothing from the network. | read-only, local only | unlinked account list |
| unlinked_get_account | One account: ETH and token balances and delegation state, read through the relay. Parameters: account (optional). | read-only, network | unlinked account show [ADDRESS] |
| unlinked_list_assets | ETH and the allowlisted tokens this network sponsors, with the jurisdiction notice. Parameters: query (optional), kind (optional), limit (optional). | read-only, local only | unlinked assets [--query Q] [--kind K] [--limit N] |
| unlinked_quote_send | Checks amount, guardrails, recipient and balance; estimates gas and work; returns a quoteId (about 2 min, one use). Parameters: account (optional), to, amount, asset. | read-only, network | unlinked quote --to ADDRESS --amount AMOUNT --asset SYMBOL [--account ADDRESS] |
| unlinked_send | Executes a quote: mines the proof of work, submits through the relay, waits for the receipt. Parameters: quoteId, maxWaitSeconds (optional). | destructive, network | unlinked send --quote QUOTE_ID |
| unlinked_get_operation | Status and receipt of an operation by userOpHash, with the explorer link. Parameters: userOpHash. | read-only, network | unlinked status USER_OP_HASH |
| unlinked_claim_test_tokens | Test networks only: claims 10 mock tokens from the faucet with a sponsored operation. Parameters: account (optional), token (optional). | additive, network, test networks only | unlinked claim [--token mNVDA] [--account ADDRESS] |
Every tool returns structuredContent that matches its published output schema, and the same JSON as text. Errors are tool
results with isError and a JSON { "error": { "code", "message", "details" } }, where code is stable: GUARDRAIL_CAP,
RECIPIENT_REFUSED, INSUFFICIENT_BALANCE, QUOTE_EXPIRED, QUOTE_USED, RELAY_REFUSED (with the relay's
reason and retryAt), RATE_LIMITED, and so on.
The CLI#
For agents that shell out instead of speaking MCP. Every command takes --json and then prints exactly one JSON object on
stdout, {"ok":true,"result":…} or {"ok":false,"error":…}; progress goes to stderr. Exit codes: 0 success, 1 failure,
2 usage or configuration error, 130 interrupted.
| Command | What it does |
|---|---|
| unlinked network | The network, epoch, difficulty, budget, guardrails and honest limits. |
| unlinked account new [--label NAME] | Generate a key, store it encrypted, print the address. |
| unlinked account list | Accounts of the local keystore for this network (no network read). |
| unlinked account show [ADDRESS] | Balances and delegation state of one account, read through the relay. |
| unlinked account export ADDRESS --reveal-private-key | Print the private key after an interactive confirmation on a terminal. CLI only: there is no MCP equivalent. |
| unlinked assets [--query Q] [--kind K] [--limit N] | Assets this network sponsors, with the jurisdiction notice. |
| unlinked quote --to ADDRESS --amount AMOUNT --asset SYMBOL [--account ADDRESS] | Dry run; prints a quoteId (about 2 minutes, one use). |
| unlinked send --quote QUOTE_ID [--max-wait SECONDS] [--quiet] | Mine, submit, wait for the receipt. Progress goes to stderr. |
| unlinked claim [--token mNVDA] [--account ADDRESS] | Test networks with a faucet only: claim 10 mock tokens. |
| unlinked status USER_OP_HASH | Status and receipt of an operation. |
| unlinked mcp | Start the MCP server on stdio (for Claude Code, Claude Desktop, Cursor…). |
$ alias unlinked='node /path/to/unlinked/packages/agent/bin/unlinked.mjs'
$ export UNLINKED_NETWORK=mainnet UNLINKED_PASSPHRASE='…'
$ unlinked account new --json
{"ok":true,"result":{"account":{"address":"0x8f3c…41aE","network":"mainnet",…}}}
# someone sends the account some USDG (that transfer links the sender to it, publicly)
$ unlinked quote --to 0x2b1d…09fC --amount 12.5 --asset USDG --json
{"ok":true,"result":{"quoteId":"q_9b1e…","gas":{"sponsored":true,"paidBySenderWei":"0",…},"estimatedSeconds":41,…}}
$ unlinked send --quote q_9b1e… --json # progress on stderr
{"ok":true,"result":{"status":"included","success":true,"userOpHash":"0x…","transactionHash":"0x…",…}}
$ unlinked status 0x… --jsonA quote made by one unlinked quote process is consumed by another unlinked send process: quotes live as 0600 files in
the keystore home and are claimed with an atomic rename, so two sends of one quote cannot both run.
Guardrails#
| Variable | Required | Meaning |
|---|---|---|
| UNLINKED_NETWORK | yes | mainnet or local. Never inferred: nothing runs until it is set. |
| UNLINKED_PASSPHRASE | yes | Encrypts and decrypts the keystore (at least 8 characters). Needed to create accounts and to send. |
| UNLINKED_HOME | no | Keystore and quote directory (default ~/.unlinked; created 0700, files 0600). |
| UNLINKED_RELAY_URL | no | Relay override (mainnet default https://relay.useunlinked.app; local: the stack descriptor). |
| UNLINKED_RPC_URL | no | Chain RPC override, used only for address-free reads (the epoch cross-check). |
| UNLINKED_STACK_FILE | no | Local only: the e2e stack descriptor (default packages/e2e/.stack.json). |
| UNLINKED_MAX_AMOUNT | no | Per-send caps by symbol, e.g. USDG=50,ETH=0.01; *=0 blocks every asset not listed. |
| UNLINKED_ALLOWED_RECIPIENTS | no | Comma-separated recipient allowlist; when set, sends to any other address are refused. |
| UNLINKED_MINER_THREADS | no | Mining threads (default: CPU cores − 1). |
- The network is explicit. Nothing runs until
UNLINKED_NETWORKis set;localreads the e2e stack descriptor.UNLINKED_RELAY_URLandUNLINKED_RPC_URLaccept https, or plain http on a loopback host only. - Caps per send.
UNLINKED_MAX_AMOUNT=USDG=50,ETH=0.01caps each send by symbol, in the asset's own decimals;*=0blocks every asset you did not list. A malformed value stops the agent instead of running without the cap. - Recipient allowlist. With
UNLINKED_ALLOWED_RECIPIENTS, any other recipient is refused. - The relay's recipient rule for every send. A recipient must be an address without code or an EOA delegated to Simple7702Account v0.9, never the sender itself or a reserved address. The relay enforces it for ETH; the agent applies it to token sends too, so an agent never sends tokens into a contract.
- Checked twice. The quote checks the guardrails, the recipient and the balance; the send checks the guardrails again (a CLI quote and its send are separate processes).
- No key in any output. Tool results, errors, progress messages and logs go through a scrubber that knows every key the process decrypted (by hash) and the passphrase; library errors are reduced to their short message, with URLs cut to their origin.
Honest limits#
- Unlinked is not a mixer: it pays gas for one operation at a time and never pools, holds or hides assets.
- Assets an address receives link the sender to it, publicly and permanently.
- The relay sees your IP address and when you send. It keeps no logs, but you are trusting that; run your own relay or use Tor if it matters.
- Every operation is public on chain: sender, recipient, token, amount and time.
- Robinhood stock tokens are not offered to persons in the US, Canada, the UK or Switzerland.
A few more facts an agent should know. Reads keyed to an address go to the relay only, one account at a time
(unlinked_list_accounts reads nothing, so the relay cannot see your accounts side by side), and the relay rate-limits
them: 10 per minute for pow_getAccountState and pow_checkRecipient together, 60 for epoch and receipt reads. A quote
reads the sender's balance, so unlinked_send pauses a random time (up to 20 s) before mining, as the SDK does after its
own reads: the relay cannot time the mining from that read. And proof of work is a rate limiter, not a guarantee: when
the epoch budget is used up, sends wait for the next epoch.
Relay JSON-RPC#
The agent talks to the relay's public JSON-RPC API. Its machine-readable description is an OpenRPC document,
/openrpc.json (OpenRPC 1.4), derived from the relay's code and
checked against it by the agent's tests, and against a live relay by its end-to-end run.
| Method | Behaviour |
|---|---|
| eth_chainId, eth_supportedEntryPoints | Answered locally (EntryPoint v0.9 only). |
| eth_sendUserOperation | Full check order, then co-sign, then forward to the relay's own bundler. Errors: -32501 (paymaster rejected) or -32503 (time range) with data { reason, failMask?, retryAt? }. |
| pow_sendUserOperation(op, ep, { delaySec }) | Same checks and co-signature; the op is held for delaySec ≤ validUntil − now − 90 s, then forwarded. It counts in the ledger while held. |
| eth_getUserOperationReceipt, eth_getUserOperationByHash | Served from chain logs (UserOperationEvent) plus the ledger for pending ops, so they work while the bundler is down. |
| pow_getUserOperationStatus | held / submitted / included / rejected / expired. |
| pow_getEpoch | Current epoch fields, acceptUntil, nextRollAt, headroomWei, budgetRemainingWei, gasProfiles, pvgQuotes, cosigner. Cached per block. Optional [{ wait: true }] only while waiting to send: an on-demand relay then rolls a due epoch. |
| pow_getAccountState(address) | Delegation, EOA nonce, EntryPoint nonce (key 0) and allowlisted token balances. Not logged. The SDK calls it only at import and after failures. |
| pow_checkRecipient(address) | Whether an ETH send to this recipient would be sponsored (no code, or delegated to Simple7702Account v0.9; not a reserved address). The SDK asks it once before mining, so a refused recipient costs no proof. Reads the recipient only; not logged. |
| eth_estimateUserOperationGas | Debugging and third-party tooling only; the SDK never calls it. Rate-limited, 4 concurrent, bodies ≤ 8 KB. |
| reason | JSON-RPC code | Meaning |
|---|---|---|
| POW_INVALID | -32501 | The proof or a mirrored on-chain check failed; failMask says which. |
| EPOCH_STALE | -32503 | The op's epoch is older than the grace window after a roll. The SDK re-mines. |
| EPOCH_EXPIRED | -32503 | Less than 60 s left in the op's validity window. The SDK re-mines. |
| NONCE_KEY | -32501 | The EntryPoint nonce key is not 0. |
| AUTH_INVALID | -32501 | The EIP-7702 authorization is wrong (delegate, chain id, nonce or signer). |
| DELEGATE_MISMATCH | -32501 | No authorization, and the sender is not delegated to Simple7702Account v0.9. |
| TEMPLATE / POLICY | -32501 | The call is not one of the sponsored templates. |
| GAS_PROFILE | -32501 | Gas limits differ from the published profile. |
| PVG_TOO_LOW / PVG_TOO_HIGH | -32501 | preVerificationGas is outside the quote bounds. The SDK re-mines on TOO_LOW. |
| FEE_MISMATCH / FEE_TOO_LOW | -32501 | Fees differ from the epoch's rule, or the bundler's floor moved. |
| SIGNATURE_INVALID | -32501 | The account signature does not recover to the sender, so the op could never validate. |
| PENDING_LIMIT | -32501 | The sender already has 4 ops pending. |
| BUDGET_EXHAUSTED / PACING | -32501 | The epoch budget is used up, or paced; retryAt says when to try again. |
| HOLD_LIMIT | -32501 | Held ops already reserve their share of the budget; retryAt is the next held op's forward time. |
| BUNDLER_REJECTED | bundler's | The bundler refused the op after co-signing. data carries bundlerCode (and aaCode); its text is never forwarded. |
| BUNDLER_UNAVAILABLE | -32603 | The bundler did not confirm the op. It may still land; the reservation is kept until it resolves. |