If you run a Bitcoin agent, you probably already know the drill: generate a secp256k1 keypair, stick the private key in an env var, load it when the process starts. Payment bots, treasury scripts, ordinals tooling. Same pattern everywhere.
The problem is that key lives in memory the whole time the agent is running. Prompt injection, a bad dependency, a log line that accidentally serializes process state. Any of those can leak it. And unlike revoking an API key, you cannot undo a copied Bitcoin private key.
We shipped Bitcoin on the Intents API so agents stop holding keys at all. The agent sends chain, recipient, and amount. The server builds the transaction, signs it in the HSM, and broadcasts. Back comes a tx_hash, not a key.
Getting a signing key
A human provisions the key once. Agents cannot create or rotate keys themselves; those endpoints return 403 on purpose.
curl -X POST "https://api.1claw.xyz/v1/agents/$AGENT_ID/signing-keys" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "chain": "bitcoin" }'You get back a secp256k1 public key and a P2WPKH bech32 address (bc1q…on mainnet). The private key goes into the org's __agent-keys vault, encrypted under Cloud KMS. MPC splitting across GCP, AWS, and Azure is optional. The API never returns the private key.
Sending Bitcoin
Signing runs through rust-bitcoin (v0.32). Your wallet spends P2WPKH UTXOs with BIP-143 via the crate's SighashCache. You can send to any valid address type on the other side: P2PKH, P2SH, P2WPKH, P2WSH, Taproot.
UTXOs and fee rates come from mempool.space. No RPC config on your end. Example:
curl -X POST "https://api.1claw.xyz/v1/agents/$AGENT_ID/transactions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"chain": "bitcoin-signet",
"to": "tb1q...",
"value": "0.001"
}'value is plain BTC as a decimal string. "0.001" is 100,000 satoshis. The server picks UTXOs, sets the fee, signs, and broadcasts. Pass fee_rate_sat_per_vbyte if you want to override the recommended rate from mempool.space.
Chains: bitcoin, bitcoin-signet, bitcoin-testnet. One quirk worth knowing: provisioning returns a mainnet-style bc1q… address, but when you submit on Signet the server derives tb1q… from the same key at signing time. Fund the testnet address, not the mainnet one.
Where signing runs
api.1claw.xyz uses Cloud KMS HSM in Cloud Run. Key material is decrypted inside the HSM boundary, used once, then cleared from memory.
shroud.1claw.xyz and intents.1claw.xyz run the same flow inside AMD SEV-SNP confidential VMs on GKE. Keys only exist in confidential memory inside the enclave. That is the option if you need attestation or a stricter compliance story.
Both support sign-only via POST /v1/agents/:id/transactions/sign. You get the signed tx hex back and broadcast it yourself. Handy if you run your own node or want to batch transactions before they hit the network.
The agent never gets the key
Flip intents_api_enabled: true on an agent and two things happen at once: it can call the transaction endpoints, and it gets a 403 if it tries to read private_key or ssh_key secrets through the normal vault API. That is enforced in the handler, not documented as a guideline.
Guardrails run before signing
Blocking key reads closes the obvious exfil path. Guardrails cap what a compromised agent can actually spend. Every Bitcoin submit runs through guardrail checks in the handler before the HSM unwraps anything. Shroud runs the same checks on shroud.1claw.xyz and intents.1claw.xyz. Configure them once on the agent via dashboard, SDK, or CLI.
- Allowed chains (
tx_allowed_chains): lock an agent tobitcoin-signetduring dev, orbitcoinonly in prod - Destination allowlist (
tx_to_allowlist): only pay addresses you have pre-approved. Treasury cold wallet, partner payout address, your own test wallet. Anything else returns 403 at signing time - Per-transaction cap (
tx_max_value): max amount in one send, in BTC for Bitcoin agents ("0.01"= 0.01 BTC) - Daily spend cap (
tx_daily_limit): rolling 24-hour limit on Bitcoin-family spend only. The server usestx_spent_today_by_chain.bitcoin, not a cross-chain total - Per-chain overrides (
per_chain_guardrails): set Bitcoin-specificmax_value,daily_limit, andto_allowlistunder abitcoinkey. Strictest of global and per-chain wins
Example: a payout bot on Signet with per-chain guardrails only:
await client.agents.update(agentId, {
intents_api_enabled: true,
tx_allowed_chains: ["bitcoin-signet"],
per_chain_guardrails: {
bitcoin: {
to_allowlist: ["tb1qYourTreasury..."],
max_value: "0.05",
daily_limit: "0.2",
},
},
});Prompt injection cannot talk its way around this. The agent submits an intent; the server checks the allowlist and caps; only then does signing happen. If anything fails, you get 403 and nothing hits the chain.
Two concurrent POSTs will not double-spend the same UTXO either. Inputs get locked in the database for five minutes while a transaction is in flight, then released after broadcast or on error.
Rotate and revoke
| Operation | Endpoint |
|---|---|
| Provision | POST /v1/agents/{id}/signing-keys |
| List | GET /v1/agents/{id}/signing-keys |
| Rotate | POST /v1/agents/{id}/signing-keys/bitcoin/rotate |
| Deactivate | DELETE /v1/agents/{id}/signing-keys/bitcoin |
Rotation mints a new keypair in the HSM, bumps key_version, and kills the old key immediately. Your agent code does not need to change.
Try Signet first
We use Signet for dev testing. Grab coins from faucet.coinbin.org (no captcha, up to 0.09 sBTC) and watch txs on mempool.space/signet. Wait for a block confirmation after the faucet sends. Unconfirmed UTXOs will not spend.
Docs: multi-chain signing keys · non-EVM transaction signing