@metalntwx/metal-agent
v0.3.3
Published
The `metal-agent` CLI and MCP server: create or import the agent's key, fund it on Metal devnet or testnet, pay x402 offers in *USDZ, look up a settlement, and set spending limits for an agent that Metal enforces.
Readme
@metalntwx/metal-agent
The metal-agent CLI an agent pays x402 payees on Metal with: create or
import a key, fund it, pay a URL, look up a settlement, or serve pay,
fund and status as MCP tools, and under a mandate its payouts, status
and verification.
npm install -g @metalntwx/metal-agent
metal-agent key create
metal-agent key import <file|hex>
metal-agent fund <address> --network testnet
metal-agent pay https://x402-demo-testnet.metalntwx.com/quote --network testnet
metal-agent status <txHash> --network testnet --rpc-url <endpoint>
metal-agent mcpnpx -y @metalntwx/metal-agent <command> runs one command without
installing.
Every command that touches the chain names --network devnet or
--network testnet; there is no default. Each network's facilitator URL,
its RPC (devnet's default endpoint, or the console that issues testnet
endpoints) and the *USDZ address come from @metalntwx/metal-pay, and
networks.json holds its demo payee URL and, on testnet, its faucet and
explorer URLs.
METAL_RPC_URL and METAL_FAUCET_URL override those defaults, and
--rpc-url and --faucet-url override both; METAL_FACILITATOR_URL and
--facilitator-url do the same for mandate pull.
On testnet, pay and fund need no RPC. status, account create and
every mandate command but credential create need an endpoint of your
own: sign up in the Metal console,
https://console-testnet.metalntwx.com, create an endpoint, and pass it with
--rpc-url or METAL_RPC_URL. On testnet, pay and fund print
an explorer line with the transaction's page on
https://testnet.metalntwx.com.
The key file defaults to ~/.metal-agent/key.json and holds the private key
in the clear, owner-readable only. key import takes a hex key (with or
without 0x), a file holding one, or a key file, and prints only the
address.
fund --network testnet asks the public faucet's POST /api/fund, and the
faucet sets the amount. An address that already holds a full grant counts
as funded. A rate-limited request fails with what to do next: retry after
the stated wait, try tomorrow, or import a key someone funded for you.
fund --network devnet [--amount <n>] spends from Foundry test account #0,
which the devnet funds at genesis.
pay refuses, before it signs, an offer in any token but *USDZ and any
offer above --max, 1 *USDZ per payment by default. A refusal at
verification, by the facilitator or the seller's own check, prints
payment refused: <reason>.
A refusal at settlement for a reason that moves nothing prints
payment refused at settlement: <reason>, with the transaction hash when
there is one; both refusals end nothing was paid. Any other failed
settlement, such as one whose answer the seller lost, prints
payment in doubt at settlement: <reason>: it may have settled.
pay sends --method (GET by default, POST with a body), --body (as application/json
unless a --header names a content type) and each repeated
--header 'name: value', and sends them again with the payment. A JSON-RPC
call to an RPC endpoint sold over x402, for example:
metal-agent pay <rpc url> --network testnet --method POST \
--body '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}'pay prints the response headers before the body, as
header <name>: <value> lines, leaving out transport, caching and CORS
headers and the payment it reports above. An RPC seller such as Metal's
answers with rpc-aas-endpoint, the endpoint the payment bought: send
further JSON-RPC calls there, without paying again, until its credit runs
out.
Spending limits
A mandate lets an agent pay from its owner's account with a P-256 key of its
own, within limits Metal enforces on chain: the most per payment, a budget
for the mandate's whole life, an expiry and, when --payee names any, the
addresses it may pay; with none named it may pay any address.
metal-agent mandate credential create --principal <MetalAccount address> [--payee <address>[,<name>[,<website>]]]... \
--cap 5 --budget 10 [--description <text>] --key <owner key file> --out ./mandate.json --network testnet
metal-agent mandate bind --credential ./mandate.json --key <owner key file> --network testnet --rpc-url <endpoint>
metal-agent pay <url> --mandate ./mandate.json --network testnet
metal-agent mandate status --credential ./mandate.json --network testnet --rpc-url <endpoint>
metal-agent mandate pull --credential ./mandate.json --to <address> --amount 2.50 --network testnet --rpc-url <endpoint>
metal-agent mandate verify --credential ./mandate.json --issuer <issuer public key file> --network testnet --rpc-url <endpoint>
metal-agent mandate verify --credential ./mandate.json --issuer <issuer public key file> --tx <hash> --signed-offer <text> --network testnet --rpc-url <endpoint>
metal-agent mandate replace --credential ./wider.json --replaces ./mandate.json --key <owner key file> --network testnet --rpc-url <endpoint>
metal-agent mandate renew --credential ./next.json --replaces ./mandate.json --key <owner key file> --network testnet --rpc-url <endpoint>
metal-agent mandate revoke --credential ./wider.json --key <owner key file> --network testnet --rpc-url <endpoint>credential create writes the limits into a credential file, a Verifiable
Intent mandate, and beside it the mandate key the agent signs with:
mandate.key.json for mandate.json. --mandate-key-file <file> names a
mandate key that exists instead, as a replacement does. The keys
that sign the credential are the owner's: they live beside the owner's key file, created
on first use (owner-key.issuer.json, with its public half in
owner-key.issuer.public.json), unless --issuer-key names their file.
--description says what the mandate is for, and --payee takes a name
and a website after the address. Every other command takes
that credential file: the owner's account, the mandate's id and its
terms derive from it. Amounts are in *USDZ, to the cent; a
mandate lasts 30 days unless --expires-in says otherwise, and its budget
never resets.
account create --out <owner key file> writes the owner's key and creates the
owner's Metal account on chain, a smart account that key controls with
threshold one; --key <file> uses a key that exists. The owner's key pays the
gas, and one that holds too little is funded first. When a later step
fails, such as a faucet refusal, the error says why and names the key file; the same command continues from it. Fund the account and name it as
the credential's --principal; bind refuses an address with no account.
The account factory is the one @metalntwx/metal-pay names for the network;
on a devnet just deploy deploys it there, and --factory <address> names
another. @metalntwx/metal-pay names the testnet's.
bind, replace and revoke are signed by the account's owners, as many as
its threshold needs. --key names the gas sponsor and defaults as the sole
signer; repeat --signer-key <file> to supply the owners' keys of an account
with several instead. The SDK's authorizeMandateCall accepts a signing
callback for mixed keys and passkeys; the CLI's signatures expire fifteen
minutes after signing. Keep owner keys outside the agent's reach: an owner key
moves the account's funds outside any mandate's limits. replace revokes the mandate
--replaces names and carries its spend into the new one; renew binds the
new one and revokes the old in one batch, starting its spend at nothing, which
is how a budget starts over.
bind, replace and renew trust the issuer beside --key unless
--issuer names others.
Verification trusts that factory, never one a payer names: it checks the account's immutable clone code against the factory's current implementation, and checks successful token-transfer receipts.
pay --mandate <credential> pays from the owner's account, signed by the
mandate key beside the credential, or the one --mandate-key-file names.
It takes neither --key nor --max. A payment
outside the mandate is refused and moves nothing, usually with a
mandate_* reason: payment refused: mandate_cap_exceeded (…). pay prints the
payment's nonce as soon as it signs; the facilitator's
/settlements?payTo=<payee> lists a settled payment by an id ending in it.
mandate pull pays an address the mandate allows directly, with no seller:
the agent's mandate key signs, and the facilitator submits the transaction
and pays its gas; --key names a funded key to submit it instead. A pull
the mandate refuses when checked is not sent: it reports the reason and
exits 1. With --key, --record-refusal sends it, so the refusal is
recorded on chain in a transaction that succeeds and moves nothing. The
outcome is read from the chain: paid (exit 0), refused (exit 1), held
(exit 4) when the payee's receive policy sends it to escrow, or pending
(exit 3); an error: line also exits 1. Each pull is kept under ~/.metal-agent/pulls/<mandate>/, and
running the same command again sends a pending pull again, which pays at
most once. --payment-id <id> names the payment: two payouts of one amount
to one payee are two payments under two ids, an id whose payout was paid or
held reports it again, after an already paid or already held line, and
sends nothing, and one the account refused may be sent with other values.
Without it, a payout is known by its payee and amount. A retry from another
home directory, or under a credential that renewed or replaced this one,
signs a new payout.
mandate verify checks the mandate and every payment under it from the
chain alone, through any RPC endpoint: the binding on the owner's account,
the credential against it and under its issuer, and each payment and
refusal as the mandate key's own signed per-payment mandate, rebuilt from its
transaction. Anyone holding the credential and the issuer's public file,
passed as --issuer, can run it; --tx <hash>
verifies one transaction, with --signed-offer <text> the offer the agent
signed for it, and a check that fails exits 1, as does an event under the
mandate that the SDK does not decode, as an account of another version emits. A seller that asks for the
agent's signed order (requireSignedOffer in @metalntwx/metal-pay/mandates,
with extra.signedOffer: "required" on the route) is paid with it, and
pay prints it. From code, verifyMandate and verifyMandatePayment do
the same, the latter for a payee that holds no credential.
skills/metal-agent/SKILL.md is an installable skill: the setup steps for
an agent, then the commands.
metal-agent mcp serves pay, fund and status over stdio, each taking
a network, and pay the request as optional method, body and
headers. What pay spends is set when the server starts, by whoever
starts it: a key file and --max (the default key file and 1 *USDZ when
omitted), or one mandate's credential file with --mandate <credential>,
which each call reads. Under a mandate it also serves mandate_pull,
mandate_status and mandate_verify, which run those commands on the
server's mandate and read the chain through its METAL_RPC_URL, or devnet's
default endpoint. The model calling the tools chooses no key, no mandate and
no higher limit; under a key, a max it passes can only lower the server's.
The other mandate commands enter only through the CLI. An MCP host starts it
with:
{ "command": "npx", "args": ["-y", "@metalntwx/metal-agent", "mcp"] }or, under one mandate, with the endpoint the mandate tools read on testnet:
{
"command": "npx",
"args": ["-y", "@metalntwx/metal-agent", "mcp", "--mandate", "/path/to/mandate.json"],
"env": { "METAL_RPC_URL": "<endpoint>" }
}Develop
In the mx56db/metal repository, just agent <command> runs the CLI from
source. Devnet fund signs through
metal-ts's wasm codec; build it once with just metal-ts-wasm.
METAL_RPC_URL=<endpoint> just agent-testnet-smoke
(scripts/testnet-smoke.ts) runs the pay quickstart against the testnet
services: it checks the
facilitator's /supported, funds a throwaway key through the faucet (or uses
METAL_SMOKE_PRIVATE_KEY), pays the demo payee, confirms the
MIP20Transfer on chain, and exits non-zero on any failure.
METAL_FACILITATOR_URL and METAL_DEMO_PAYEE_URL point it elsewhere.
Run pnpm test and pnpm typecheck.
