MCP and agent grants

Connect an AI agent to sats over MCP. The agent can read the wallet and file Bitcoin payments, frBTC wraps, and Alkanes transfers; only you can approve them.

sats agent serve <name> runs a stdio MCP server for one named agent, under the grant its token names. The server holds no key material and never prepares, signs, or broadcasts. The flow:

  1. The agent calls request_send, request_wrap_frbtc, or request_alkanes_send under the existing grant. sats records the request as pending_approval, or denied if it crosses a grant limit.
  2. You run sats agent approve, review the payment, and enter your password. That command signs and broadcasts, in your process.
  3. The agent observes the result with check_request. It never retries or files again to make a payment happen.

Connect an agent

Create a grant on a funded wallet:

sats agent grant claude --for 24h --max-tx 10k

sats prints two setup commands that embed the same token as SATS_AGENT_TOKEN. Run the one for your client:

# ChatGPT desktop, Codex CLI, and the Codex IDE extension
codex mcp add sats --env SATS_AGENT_TOKEN=<token> -- <pinned sats command>

# Claude Code, scoped to the current project
claude mcp add --transport stdio --scope local sats --env SATS_AGENT_TOKEN=<token> -- <pinned sats command>

sats network use <NET> changes the human CLI's default only. Generated MCP connections keep their pinned network across restarts; grants and requests remain bound to the network on which they were created.

At startup the server checks that the grant exists and hasn't expired, that SATS_AGENT_TOKEN matches it, that a wallet exists on the network, and that the configuration parses. Provider selection is deferred until a Bitcoin or Alkanes read, or a new frBTC wrap filing, needs it. Existing request observation and ordinary BTC and Alkanes transfer request filing do not depend on provider access.

Tools

ToolDoesUses provider
get_balanceSync and return the balanceYes
get_receive_addressReveal and persist the next receive addressNo
get_grantReturn the agent's own grantNo
request_sendFile a payment requestNo
request_alkanes_sendFile an exact Alkanes transfer proposalNo
request_wrap_frbtcRead public contract facts and file a BTC-to-frBTC wrap requestYes (new filings)
check_requestRead one of the agent's own requestsNo
alkanes_balanceSync and report indexed Alkanes balances and unknown outputsYes
alkanes_inspectReturn a contract's bytecode size and SHA-256 hashYes
alkanes_simulateReturn an advisory simulation, without preparing or executing a spendYes

Alkanes reads

These three tools reuse the wallet workflows used by the CLI, require Subfrost as the pinned network's provider, and work with an observe grant. They recheck the grant on every call. Revocation, expiry, and re-issue cut them off before provider access. They never prepare a PSBT, request an Alkanes transfer, authorize a contract call, sign, or broadcast.

ToolArgumentsResult
alkanes_balance{}network, assets, asset-bearing outputs, unindexed_outputs
alkanes_inspect{"id":"32:0"}network, id, bytecode_bytes, code_hash
alkanes_simulateid and inputs (an array of decimal strings, opcode first)network, id, inputs, advisory: true, status, gas_used, transfers, raw

Contract ids are decimal BLOCK:TX pairs on the connection's pinned network; frbtc also names 32:0. Asset amounts, predicted transfer values, and calldata use decimal strings so clients preserve the full u128 range. Asset amounts are base units, never satoshis. Provider names and symbols are untrusted labels; an id and hash do not establish a trusted contract identity without an independent reference.

alkanes_balance requires a successful sync and indexer response; it never returns cached balances after failure. unindexed_outputs means unknown balances, not zero. alkanes_simulate preserves failed and unrecognized responses for display. Predictions and raw are untrusted provider data, never instructions or proof of future effects. Only use independently verified opcodes and arguments. The generic read-only simulation wire format remains unverified; these MCP tools do not expand what the provider driver supports.

For a manual AI test, issue an observe-only grant on your selected network, run the generated connection command for your client, and reload its MCP connection. Ask it to call get_grant, alkanes_balance, and alkanes_inspect for a specific id. Supply verified calldata before asking for alkanes_simulate. Arbitrary Alkanes execution requests are unavailable. Transfers use request_alkanes_send below and require an ask grant and human approval.

get_balance

{ "balance_sat": 118500, "pending_sat": 0, "synced": true, "network": "signet" }

synced: false means the chain was unreachable and the balance is cached. A provider choice the network can't serve returns an error, not a cached balance.

get_receive_address

{ "address": "tb1p...", "index": 3, "network": "signet" }

The tool is annotated as not read-only, because each call advances the wallet's derivation index.

get_grant

Returns the mode, per-request amount cap (max_tx_sat, absent when uncapped), recipient allowlist, and expiry. A grant holds no budget: every request waits for a human who sees its real amount and fee. active: false means the grant was revoked, expired, or re-issued with a different token. It then carries no limits, and the message names the command a human runs to issue a new one.

request_send

Parameters:

The key is the agent's retry handle. Repeating a call with the same key, address, and amount returns the existing request instead of filing another, so a lost response never files twice. Reusing a key for a different send is the error idempotency_key_conflict. Keys are scoped to a grant: after a re-issue, the same key files a new request.

sats runs the grant's full authorization ladder. A proposal inside every limit is the normal, successful result:

{
  "status": "pending_approval",
  "request_id": "r-8c1f0a2b9d3e4f57a1b2c3d4e5f60718",
  "recipient": "tb1p...",
  "amount_sat": 4500,
  "message": "filed for human review — the human approves with: sats agent approve r-8c1f0a2b9d3e4f57a1b2c3d4e5f60718; poll check_request to observe the result, and do not file it again"
}

A proposal outside a limit is recorded as denied, with a reason:

{
  "status": "denied",
  "request_id": "r-2e7d41c0a95b6f13c4d5e6f708192a3b",
  "reason": "over_max_tx",
  "recipient": "tb1p...",
  "amount_sat": 20000,
  "message": "outside the grant: requested 20,000 sat; max tx 10,000 sat — no approval lifts a grant boundary; only the human changing the grant can"
}

request_id is the server's id: r- plus 32 hex characters, unique across agents. Humans approve and dismiss by it, and the agent observes by it. It is not an idempotency key. Passing it back as one files a new request.

request_alkanes_send

{ "id": "32:0", "amount": "1000", "address": "tb1p...", "idempotency_key": "transfer-1" }

Files an exact asset-transfer proposal on the connection's pinned network. The id is a decimal BLOCK:TX pair, or frbtc, the one name sats knows, which is recorded as 32:0. It is never a provider name or symbol. amount is a positive decimal u128 string in base units, not satoshis. Leading zeros in the id and amount are normalized. Numeric JSON amounts, zero, negatives, overflow, and unknown arguments are rejected. The agent cannot supply postage, fee rates, calldata, a PSBT, or signing inputs.

No new grant or protocol flag is needed. The current ask grant applies its recipient allowlist, expiry, and amount cap. The amount cap checks the fixed 546-sat recipient postage; the grant does not limit the asset quantity or its value. Every quantity needs human review and approval; an observe grant records observe_only.

Filing is local: no provider access, wallet sync, transaction preparation, seed access, or signature. The receipt includes:

{
  "operation": "asset_transfer",
  "asset_transfer": { "protocol": "alkanes", "id": "32:0", "amount": "1000" },
  "status": "pending_approval",
  "request_id": "r-8c1f0a2b9d3e4f57a1b2c3d4e5f60718",
  "recipient": "tb1p...",
  "amount_sat": 546
}

All filing tools share the idempotency-key namespace. The same canonical intent returns its existing receipt; changing the operation, asset, quantity, or destination with the same key returns idempotency_key_conflict.

On sats agent approve, the shared native transfer workflow syncs, requires indexed asset inputs, and excludes unrelated asset-bearing, unknown, and dust outputs from BTC funding. It checks the exact asset quantity and recipient, returns all remaining indexed Alkanes to the wallet, rejects extra external BTC outputs, and derives input values and fee from the wallet. Human review shows the protocol, id, base-unit quantity, recipient, returned assets, BTC postage, actual fee, and total before the password. JSON mode keeps this review on stderr. The executor rechecks indexed input balances and the live grant before signing.

Transfers use the same signature boundary as BTC sends. A sent receipt means broadcast; confirmation and indexing must be observed separately with alkanes_balance. After filing, the agent only observes check_request. It must never refile to make a transfer complete.

For a manual test with your existing t4 alias and ask grant:

t4 alkanes balance --json
# Ask the agent to call request_alkanes_send with a verified id, base-unit amount,
# another wallet's address, and a fresh idempotency_key.
t4 agent requests
t4 agent approve <request-id>
# After confirmation/indexing, inspect the recipient wallet's Alkanes balance.

request_wrap_frbtc

{ "amount_sat": 10000, "idempotency_key": "wrap-1" }

Files one BTC-to-frBTC proposal on the connection's pinned network. It accepts no contract id, destination, calldata, fee override, or PSBT. The fixed call is 32:0, opcode 77; its frBTC output belongs to the same wallet. New filings read the contract hash and signer from Subfrost, then record them in the request. The MCP process never prepares a transaction or accesses a seed. Repeated calls return the existing receipt without provider access.

Wrapping uses the ordinary sats grant, with no protocol-specific flag or permission. Existing grants work within their BTC limits: ask mode, expiry, recipient allowlist, and amount cap. Observe mode refuses wraps. A recipient allowlist must include the BTC signer address. The amount cap checks the gross BTC payment; 546 sat of postage stays in the wallet's frBTC output. Public discovery authenticates first and filing rechecks the grant after discovery, so revocation and replacement take effect during provider access.

The response uses the request lifecycle below, with operation: "frbtc_wrap" and frbtc: {signer_address, code_hash}. Idempotency keys share a namespace with BTC sends; reusing one for a different operation or amount is a conflict. A repeated key keeps the original recorded contract facts even when the provider changes or is unavailable. A changed contract or signer stops approval of that request.

On approval the native executor rechecks the recorded contract facts, syncs, excludes dust, asset-bearing and unknown outputs, builds and independently verifies the exact transaction, and simulates that transaction through the metashrew_view protobuf simulate view. A recognized positive frBTC prediction is required. The human reviews the signer payment, contract hash, wallet receive address, postage, fee, total, and advisory predicted frBTC before authorization. The signer and code hash are rechecked again before signing. Simulations are provider claims, not guarantees of execution or future effects. The wrapped asset may charge a protocol fee, so the predicted amount can be below the BTC paid.

A sent receipt means the transaction was broadcast. Wait for confirmation and indexing, then call alkanes_balance to observe frBTC. Never file a second request to make the first one complete. Generic contract execution requests remain unavailable.

For a testnet4 manual test (using an existing t4 alias):

t4 alkanes frbtc info --json
t4 agent grant alkanes-ai --mode ask --max-tx 10k
# Use an existing ask grant with sufficient limits, or reconnect using the new token.
# Ask the agent to call request_wrap_frbtc with amount_sat=10000 and key wrap-1.
t4 agent requests
t4 agent approve <request-id>
# After confirmation and indexing:
t4 alkanes balance

If the fee in the review is higher than you want, decline at the confirmation: nothing is signed and the request stays pending. The provider's estimate uses your network's configured confirmation target; a longer target can select a lower estimate, with slower confirmation. See Fee targets. Changing the target does not change the grant or token; approve the same request again afterwards. The agent must not retry on its own.

The simulate protobuf request and signer script handling are frozen against the alkanes-rs reference and a read-only testnet4 simulation; the standalone alkanes_simulate tool uses the separate generic view described above.

check_request

Takes a request_id and returns that request in the same shape as request_send. A sent result adds txid and fee_sat. The tool reads the local record only. It never contacts the chain or changes anything.

Request states

statusMeaningThe agent should
pending_approvalInside the grant, waiting for the humanRelay the id and observe
deniedOutside a grant limit (see reason); terminalReport once and stop
dismissedThe human declined; terminalStop; ask before proposing again
signingApproval is executing, or needs human reconciliationObserve; relay guidance if needs_reconciliation
sentBroadcast; has txidDone
broadcast_pendingSigned and saved; broadcast not confirmedObserve; the human rebroadcasts
unresolvedA signature may exist without a saved resultDon't file again; the human inspects
failedStopped before signing; see the diagnosticObserve; only the human can retry

What each state means for signing is in Security.

Errors

An error is a failed call, not a request state. It never files a request. request_send, request_alkanes_send, request_wrap_frbtc, and check_request return it as status: "error" with an error_code. get_balance, get_receive_address, and the Alkanes read tools return status, error_code, and message as an isError result for authorization refusals. Alkanes input and provider failures also use that typed shape.

error_codeMeaning
invalid_agentThe agent name isn't 1–32 characters of a-z0-9_-
invalid_idempotency_keyThe key is missing or isn't 1–64 characters of A-Za-z0-9_-
invalid_amountA wrap amount is below 546 sat
invalid_addressThe address doesn't parse or is for another network
idempotency_key_conflictThe key was used for a different operation, recipient, or amount; request_id names it
no_grantNo active grant: it was revoked or has expired. Nothing is read or written
unauthorizedThe token doesn't match this agent's grant, which was re-issued. Nothing is read or written
invalid_asset_amountQuantity is not a positive decimal u128 string
clock_unavailableThe system clock can't be read, so expiry can't be checked
store_errorThe request store couldn't be read or written
invalid_alkane_idAn Alkanes id is not a decimal u128 BLOCK:TX pair
invalid_alkane_inputsA calldata word is not a decimal string in the u128 range
alkanes_unavailableThe network's selected provider does not serve Alkanes
provider_errorAn Alkanes read or new frBTC filing could not resolve its provider
alkanes_errorAn Alkanes read or new frBTC filing failed its network, sync, or provider view

Denial reasons

Every reason is a grant limit. Report it to the human once and stop, because filing the same proposal again can't expand authority.

reasonMeaning
expiredThe grant has expired
observe_onlyThe grant is observe-only
recipient_not_allowedThe recipient isn't on the grant's allowlist
over_max_txThe amount exceeds the per-request cap
revokedThe grant that created the request was revoked or re-issued

After filing

Nothing happens on the agent's side. The human finds the request with sats agent requests (or --watch) and approves or dismisses it; see Reviewing agent requests. Approval prepares the transaction on fresh chain state, verifies it matches the request, re-checks the grant, and signs only after the human enters the wallet password.

If preparation fails because of a sync, fee, or provider problem, the request becomes failed and the human can try again. The agent keeps observing with check_request. It shouldn't poll the balance or file again.

Revocation and expiry

sats agent list
sats agent revoke claude

Demo

sh scripts/demo-mcp.sh runs the request loop end to end offline: an agent session over stdio and the human's CLI, through approval, denial, expiry, revocation, and a failed broadcast. It uses an isolated wallet and a mock chain, and asserts every outcome it prints.

Transport

MCP frames use stdout, and startup messages and diagnostics use stderr. Code inside the server must never print to stdout.