@kuiralabs/arc-mandate
v0.1.8
Published
Let a coding agent spend a bounded allowance, and call the functions an app names, granted from your phone and enforced by the chain. Arc and Monad.
Maintainers
Readme
Give your coding agent an allowance
An MCP server, so the agent you already run — Claude Code, Cursor, Codex — can spend from your wallet inside limits you set on your phone and take back with your face.
claude mcp add arc-mandate -s user -- npx -y @kuiralabs/arc-mandateThe wallet it spends from opens in your phone's browser at https://kuiralabs.github.io/mandate/, on Arc testnet, Monad testnet and Arc mainnet alike. The connector names it when it shows its code. Then, in the agent:
you: what's your payment address? agent: (calls
get_pairing_address) → a QR code, with0x1f94…F107under it— you grant it $10 in the app, with Face ID —
you: buy me the ACME filings agent: (calls
pay) → paid $2. Remaining: $8.
How pairing works, and why it is one step
The agent generates its own key on first run and never sends it anywhere. A mandate is granted to an address, so only a public value moves, and only from the agent to you.
Granting needs nothing but that address, and the address is public, so the agent does not take the
first grant it finds. Its code also carries a one-time pairing code, and the app writes a hash of
it into the grant. After you grant, the agent asks the chain for the SessionKeyAdded event naming
its own address and carrying its code. Both are indexed in the event, which is what makes a single
scan enough: no second round trip, no config file, and no copy-paste back.
A grant made any other way, by a stranger or from an address typed on its own, carries no code the
agent is waiting for, and is not used. The first grant carrying a code wins, so a copy made after
yours cannot take its place. Every answer about spending names the wallet it comes from, so you can
match it to the app. To move the agent to another wallet, ask it for its code again and scan it from
that wallet. The code is also saved as pairing-code.png beside the agent's key.
Tools
| | |
|---|---|
| get_pairing_address | the code to grant to: a QR carrying the agent's address and a one-time pairing code, for the app to scan, also saved as an image. Says which wallet the agent spends from, if one is paired |
| check_allowance | limit, spent, remaining and what is spendable now, read from the chain, and the agent's ERC-8004 identity, which it sets up the first time for each wallet |
| pay | send USDC to an address, within the allowance |
| call | call one function an allowance names, on an app's contract, sending the coin with it when the function takes payment |
| sign_statement | sign an app's own structure, so its door can see which agent is knocking, for an app whose contract the allowance reaches |
| buy | fetch a URL and pay if it answers 402 Payment Required (x402), topping up the agent's balance at Circle's Gateway from the allowance when it has to |
| top_up | move money into the agent's balance ahead of a run of small purchases; it counts against the same allowance |
The agent cannot exceed the allowance or keep spending after you revoke, and an allowance that names payees cannot pay anyone else. Nor can it touch anything else in your wallet: every allowance is an allowlist naming the three things an agent does, paying in USDC, filling its balance at Circle's Gateway, and setting up its own identity, so your other tokens and NFTs are out of its reach. None of that is enforced here: the account enforces it. A revoked or expired allowance is refused during validation, before anything runs. On the one-meter allowance the app grants, an over-limit payment is refused when it runs instead: no money moves, and the gas is Circle's sponsorship. This connector checks the limit before sending, so only a connector that skipped the check reaches that refusal.
Proving who the agent is, to an app
A seller that charges learns who the agent is by being paid: the payment carries a signature, and the
payer's address is the agent. An app that charges for nothing, and only wants to know whose agent is
at its door, needs the same proof without the money. That is sign_statement.
What it signs is the app's own structure, in the app's own domain, never a sentence and never a bare hash. That is not a style choice. An operation is authorised by a plain signature over its hash, so a key that signs whatever text it is handed hands out something that can be submitted as an operation. A structure carries its domain's separator, so the signature is unusable anywhere but that app, cannot be read as an operation, and is never the wallet's: the plugin validates operations and nothing else, so a session key's signature is only ever the agent's own, and the agent owns nothing.
An agent may sign only for an app whose contract its allowance reaches, read from the chain, so an
allowance for one app is not a licence to sign in another's name. The app publishes its structure;
the agent writes it exactly as published, the way it writes a function for call.
The agent's identity
A seller that rewards agents, like the maze, credits an ERC-8004 identity and gives its badge to the
identity's owner. So the first time check_allowance runs for a wallet, the agent sets one up: it
registers an identity from your wallet, which therefore owns it, and links its own key to it with
its own signature. That is two operations, sponsored like its payments, and neither moves money.
The allowance names exactly those two calls on the registry, and not the ones that would move an
identity.
check_allowance then reports the identity's number, and the agent gives it wherever a seller asks
for an agent id: for the maze, POST /game?agent=<id>. What the agent earns is written to that
identity, and the badge goes to your wallet. Pair the agent with another wallet and it sets up
another identity, owned by that one.
Configuration
| | |
|---|---|
| ARC_MANDATE_NETWORK | which network the agent spends on: arcTestnet (the default), monadTestnet, or arc for Arc mainnet, which is real money. On mainnet the owner's wallet is a separate one, made with its own passkey |
| ARC_MANDATE_RPC_URL | an endpoint for whichever network is chosen, ahead of every other setting |
| ARC_TESTNET_RPC_URL | the endpoint on Arc testnet, and the one that wins there: the connector reads ARC_TESTNET_RPC_URL ?? ARC_RPC_URL ?? Arc testnet. This is the name .env.example uses, so it is the one already set if you copied that file |
| ARC_RPC_URL | the same knob under an older name, used only when ARC_TESTNET_RPC_URL is unset. Setting this one while the other is set changes nothing, silently |
| ARC_SESSION_KEY_PLUGIN | the mandate plugin |
| CIRCLE_CLIENT_URL, CIRCLE_CLIENT_KEY, CIRCLE_PASSKEY_DOMAIN | the bundler the agent submits through. Arc has no public bundler. Leave all three unset and the connector uses the shared key for its network, on Arc testnet and on Arc mainnet; set all three to use your own. Set only some and nothing is filled in: the missing ones are named |
| ARC_MANDATE_KEY_PATH | where the agent's key lives (default ~/.arc-mandate/agent.key). The code image is saved beside it |
| ARC_MANDATE_ACCOUNT_PATH | where the paired wallet is remembered. Move or delete this and the agent forgets which wallet it spends from, and pairing starts over |
| ARC_ACCOUNT | a wallet to spend from without pairing, for a connector you point at your own wallet by hand. Still checked against the chain before every use |
| ARC_CHAIN_ID | overrides the chain id the connector expects. Set only if you are pointing it at something other than Arc testnet |
Installing it
One line, with Node 22 or later:
claude mcp add arc-mandate -s user -- npx -y @kuiralabs/arc-mandateOn Arc testnet that is all: nothing to clone and nothing to configure. For Arc mainnet, add a second connector beside it, since each one spends on its own network only:
claude mcp add arc-mandate-mainnet -s user -- npx -y @kuiralabs/arc-mandate mainnet
``` Restart the client afterwards;
MCP servers are launched at startup. Without `-s user` this lands in local scope, which is bound to
the directory you ran it in and shadows every other scope.
The package is the compiled JavaScript of this folder, one file per source file, neither minified
nor bundled: it holds a key and signs payments, so what it does should be readable where it is
installed. Values of your own, such as a Circle key of your own, go in the client's config as an
`env` block.
### From a clone
For working on the connector itself:
```bash
cd /path/to/arc-agent-mandate
claude mcp add arc-mandate -s user -- node "$PWD/mcp/server.ts"
claude mcp list | grep arc-mandate # confirm the path; $PWD is your shell's, not oursThis runs the TypeScript directly, which needs Node 22.18 or later. The server reads this
project's .env if there is one; do not copy .env.example just to have one, since its values
are placeholders and a placeholder endpoint is worse than none.
Publishing
From this folder, npm publish; the package says it is public, since npm makes a scoped package private unless told. Packing builds dist/ first (prepack), and
package.test.ts builds it the same way and starts it as an MCP client would, so a change that
would break the package fails the gate instead of a stranger's install.
Whose key pays the gas
Arc has no public bundler. Circle's is the only way to get a user operation on chain, and their Gas Station is what pays the gas so that neither you nor the agent has to. Somebody's account is behind that.
On testnet it is ours. With none of the three Circle values set, the connector uses the client key the wallet's web build already publishes at https://kuiralabs.github.io/mandate/, bound to that domain. Nothing about it is secret: it is a TEST key, which Circle refuses on mainnet, and it authorises nothing on its own, since a spend still needs a signature your allowance permits. What it shares is the gas, which on testnet is test money. That was chosen on 25 September, so that connecting an agent needs no Circle account. It reversed the earlier "bring your own key", at first for testnet only.
On Arc mainnet it is ours too, with a cap. Chosen on 14 October, for the same reason: so an owner's agent pays on mainnet with nothing to set up. The key is a LIVE one, and public all the same, since the wallet's page has to carry it to work. What it shares is the gas, which there is real money on our Circle account, so the Gas Station policy behind it limits each operation and each day. A shared key with no cap would be a bill rather than a design; with one, it is a convenience with a known ceiling. Set your own three values and the connector uses them instead.
That is the whole developer setup. Everything after it is scanning.
Using it
Ask the agent for its address. It prints a code, and saves it as an image beside its key.
Scan the code in the app, choose an amount and how long it lasts, confirm with Face ID.
That is the whole thing. From then on the agent spends inside the allowance without asking, and cannot spend outside it however it is asked — not because it is well behaved, but because the account refuses.
Why the agent holds nothing
An allowance is authority, not a balance. An agent that keeps even a fraction of a dollar has broken that promise, and a person who sees money trickle out to agents will not trust the mechanism however small the trickle is.
That rules out having the agent submit for itself. A user operation travels inside an ordinary transaction, and a transaction's sender must hold a balance before it can send one — so a self-submitting agent has to be funded first and is left with dust afterwards, which nothing ever returns. The account has no claim on it; only the agent's own key can send it back.
So the agent hands its operations to a bundler. On Arc there is exactly one — Circle's — and that
is checked rather than assumed: Arc's public RPC answers eth_supportedEntryPoints with "method
not supported", while Circle's returns EntryPoint v0.7.
The credential that needs is not a secret. It is the same client key the mobile app already ships inside its bundle, bound to a passkey domain, and it authorises nothing by itself: a spend still requires a session-key signature the mandate permits. Handing it to the agent gives away no authority, which is what makes this a better trade than leaving money behind.
The paymaster then covers the operation, so the account pays no gas either. That closes a
second problem which is easy to miss: submitting directly, the account was billed for everything
the agent did and the mandate never counted it, because the spend limit bounds call.value and
gas is not call.value. An agent could send a trivial amount inside an expensive operation and
cost the account far more than its allowance. Sponsored, there is nothing to bill.
The security shape is unchanged and simpler to state: a compromised agent gets whatever the mandate still allows, and nothing else — no float, no wallet.
