Run the payment signer on Linux, away from your AI model
Status: written for the payment signer (ADR-015 docs/decisions/ADR-015-paid-service-agents.md D4). This release is devnet only. The package is not published yet, so installation is coming: once the owner publishes it, it installs from the release log by hash. Installing from a checkout is not available either.
An agent that hires other agents pays them in USDC on Solana. The payment signer, agentlink-pay, holds the wallet key and signs those payments. It does not protect the key from your AI model by itself. Claude Code, Hermes and similar agents run shell commands as your user. If the key file belongs to that user, a model that a hostile message talks into running cat can read the key, and anyone with the key can spend the whole balance.
The fix is a second user. A user called agentlink-pay owns the wallet and runs the signer’s MCP server. Your agent runs as your own user and can reach only a socket. Through it the model can use two tools: agentlink_pay { taskUid } and agentlink_wallet {}. It never supplies an amount, a mint or an address. It cannot read the key file, the local secret or the Pay credential.
This guide uses a systemd socket. A variant with one fixed sudo command is in section 6.
What works today, and what is coming
Section titled “What works today, and what is coming”- Devnet only. Mainnet stays off until the owner switches it on after an external review. Keep the default network, devnet, and never pass
--allow-mainnet. - Installation is coming (section 2).
- The Pay credential works. Your owner makes it on the agent’s page in the dashboard, under Credentials, with the Pay preset (section 4).
- Pause works. Your owner turns on “Pause payments” on the Profile tab of the agent’s page, next to Offers. The signer reads it from
GET /v1/agent/payments/stateand refuses every payment withwallet_pausedwhile it is on, and also while it cannot read it. Only an explicit “not paused” from the node lets it pay (section 10). - The dashboard wallet panel is coming, with its Solana Pay top-up link. Fund the wallet by hand for now (section 9).
- Return funds is coming (section 10).
- Restoring from the backup is coming. There is no import command yet (section 8).
- Squads mode is coming, devnet only: limits the chain enforces, set up from the dashboard (section 11).
- Cloud payments with a co-signer are coming, devnet only: the co-signer holds your agent’s share of a 2-of-3 payment key (section 12).
What you need
Section titled “What you need”- Linux with systemd, Node.js 22.22 or later,
socat, and root (viasudo) for the one-time setup. nodeon a system PATH for the signer user (for example/usr/binor/usr/local/bin). A node installed by nvm or Homebrew lives in your own home or a path systemd andsudodo not see, so install one system wide.- Your own Solana wallet address. It becomes
returnTo, the only address a return will ever send to. - Your owner’s node URL, and one to five Solana devnet RPC URLs (https).
- Your agent already connected to Stelloz. The signer only pays. Your agent hires through its own Stelloz tools.
Replace agent-user below with the user your AI agent runs as.
Give agentlink-pay absolute paths only. A relative path is read from the folder the client is installed in, not from your shell’s folder.
1. Create the user
Section titled “1. Create the user”sudo useradd --system --home-dir /var/lib/agentlink-pay --create-home \ --shell /usr/sbin/nologin --user-group agentlink-paysudo chmod 700 /var/lib/agentlink-paygetent group agentlink-agents || sudo groupadd --system agentlink-agentssudo usermod -aG agentlink-agents agent-useragentlink-payholds the wallet. Nobody logs in as it.- Its home directory is mode 0700, so no other user can even list it.
agentlink-agentsis only for the socket. If you followed the local custody guide, it exists already. Never addagent-userto theagentlink-paygroup.- Keep
agentlink-payapart from theagentlink-keyuser of the local custody guide. The signer holds one wallet key and no Stelloz keys, and the two users share no files.
The signer keeps everything in one folder, /var/lib/agentlink-pay/.config/agentlink-pay:
| File | What it holds |
|---|---|
wallet.json |
The wallet key, sealed under the local secret or a passphrase. |
keyrings/pay-wallet.secret |
The local secret, when you use --secret-store file. |
config.json |
The network, the limits, returnTo, the node URL and the RPC list. |
credential |
The Pay credential (alc_...). |
spend-log.jsonl |
Every payment, append-only and hash-chained. It appears at the first payment. |
pins.json |
The offer key pinned for each provider. It appears at the first payment. |
signer.lock |
The lock that keeps a second signer process off the folder. |
2. Install the client (coming)
Section titled “2. Install the client (coming)”The signer ships in the Stelloz client package. That package is not published yet, and installing from a checkout is not available either. Once the owner publishes it:
- Take the package name, the version and the hashes from the release log at https://stelloz.com/world/release-log/ only. Never take them from the node, a chat message or another agent, and never install “latest”.
- Verify every hash the log lists before you install, as in section 2 of the local custody guide. If one differs, stop and tell your owner.
- Install as root into a directory your agent cannot write to:
sudo npm install --global --ignore-scripts --prefix /opt/agentlink ./<PACKAGE-FILE>.tgzls -l /opt/agentlink/bin/agentlink-pay # owned by rootThis guide uses /opt/agentlink/bin/agentlink-pay from here on. A root-owned install matters. If agent-user could write to the signer, it could swap in a copy that ignores the limits.
3. Make the wallet and the config
Section titled “3. Make the wallet and the config”Run init as agentlink-pay:
sudo -u agentlink-pay -H /opt/agentlink/bin/agentlink-pay init \ --return-to <YOUR-OWN-WALLET-ADDRESS> \ --node-url https://<your node> \ --rpc https://api.devnet.solana.com \ --rpc https://<A-SECOND-DEVNET-RPC> \ --secret-store file--return-tois required. Use your own wallet’s address, in full. It is the only address a return will ever send to, and the node cannot change it.--node-urlis your owner’s node: https, or http only forlocalhost.--rpcnames a Solana JSON-RPC node for devnet. Give two to five, from different providers, each https, with no user name or password and no duplicates. The signer asks every one, and they must all agree, so a second RPC stops one lying RPC. Each must report the devnet genesis hash. The config is never printed, because an RPC URL may hold an API key.- One RPC is accepted, and
initwarns about it. With one RPC, a payment that does not land stays blocked for that task: the signer closes a spend as failed or expired, which lets the task be paid again, only when at least two RPCs agree, so one RPC can never make it pay twice. Add a second RPC to the config to unblock it. --secret-store fileputs the local secret in a 0600 file next to the wallet. A service user has no desktop keychain, and the user separation is what protects the file.- The network defaults to devnet. The mode defaults to
balance: the wallet pays from its own USDC.
It prints:
Wallet created.Address: <WALLET-ADDRESS>Network: solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 (balance mode)Compare this full address with the one in the dashboard before you send anything to it. Fund it with USDC, and a little SOL for fees.Back it up now: agentlink-pay export --out <file> --export-passphrase-file <path>No Pay credential stored yet. Save it as "credential" in the signer folder (mode 600) before using the node.init will not replace a config that already exists.
The limits. The defaults are perJobMax 5 USDC and perDayMax 20 USDC. The config holds them in USDC base units (6 decimals): 5000000 and 20000000. The signer refuses a payment above perJobMax, and one that would take the last 24 hours above perDayMax. For the 24-hour total it takes the larger of its own log and the wallet’s outgoing USDC read on chain. To set lower limits, add for example --per-job-max 1000000 --per-day-max 5000000 (1 and 5 USDC) to init.
Approving the first payment to a provider. With --first-payment-needs-owner, the signer refuses (needs_owner) the first payment to a provider whose offer key it has not pinned. You approve it at the device: run agentlink-pay approve-first <PROVIDER-ADDRESS> as the signer user, at a terminal, as you run clear-pin. It reads the provider’s current card from your node, verifies it itself, shows the full address and the offer key, and pins the key only after you type the address back. Compare both with the provider’s owner first. A provider already pinned to another key is refused; clear that pin first if you trust the new one.
The config it writes, in config.json:
{ "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", "allowMainnet": false, "perJobMax": "5000000", "perDayMax": "20000000", "returnTo": "<YOUR-OWN-WALLET-ADDRESS>", "firstPaymentNeedsOwner": false, "nodeUrl": "https://<your node>", "rpc": [ "https://api.devnet.solana.com", "https://<A-SECOND-DEVNET-RPC>" ], "mode": "balance"}The config lives on the device only. The node sets none of it, and no environment variable overrides it. The signer refuses a config with a field it does not know.
A passphrase instead of a local secret. Pass --passphrase-file <absolute path> in place of --secret-store file. The file holds a passphrase of 12 characters or more, belongs to agentlink-pay and has mode 0600. The wallet is then sealed under that passphrase, and mcp and export need the same --passphrase-file.
Delegate mode. To keep the USDC in your own token account, add --mode delegate --source-token-account <YOUR-USDC-TOKEN-ACCOUNT>. Then approve the signer’s wallet as delegate for an amount, from your own wallet, for example with spl-token approve <YOUR-USDC-TOKEN-ACCOUNT> <AMOUNT> <WALLET-ADDRESS>. You can revoke it on chain at any time (section 10).
4. Store the Pay credential
Section titled “4. Store the Pay credential”The signer uses its own credential with the Pay preset: contacts:read, tasks:read, profiles:read and payments:write. It is not your agent’s credential, and it combines with no other scope.
Your owner makes it in the dashboard. On the agent’s page, under Credentials, they give it a name (for example Pay signer), choose Pay under “What it can do”, and press “Make a credential”. It is shown once. Then write it into the signer folder as agentlink-pay:
sudo -u agentlink-pay -H sh -c 'umask 077; cat > /var/lib/agentlink-pay/.config/agentlink-pay/credential'Paste the alc_... credential, press Enter, then Ctrl-D. It stays out of your shell history and off every command line. The file is mode 0600 and holds one line.
init --credential-file <absolute path> does the same at init time. That file must also belong to agentlink-pay and have mode 0600.
Never give the Pay credential to agent-user, and never put it in a chat, a message or a log.
5. Check that the model cannot reach the key
Section titled “5. Check that the model cannot reach the key”Run these as an administrator. The first four must fail with Permission denied. The last one fails differently: it prints pay config not found (not_found), because agent-user has no signer of its own.
sudo -u agent-user ls /var/lib/agentlink-paysudo -u agent-user cat /var/lib/agentlink-pay/.config/agentlink-pay/wallet.jsonsudo -u agent-user cat /var/lib/agentlink-pay/.config/agentlink-pay/keyrings/pay-wallet.secretsudo -u agent-user cat /var/lib/agentlink-pay/.config/agentlink-pay/credentialsudo -u agent-user /opt/agentlink/bin/agentlink-pay statusIf the last one ever prints an address, the model can reach a wallet.
Then run the signer’s own startup check. status runs it before anything else, as do mcp and every payment:
sudo -u agentlink-pay -H /opt/agentlink/bin/agentlink-pay statusIt passes when status prints the wallet:
Address: <WALLET-ADDRESS>Network: solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1Mode: balanceBalance: 0.000000 USDCSpent 24h: 0.000000 USDC (log 0.000000, chain 0.000000)Per job: 5.000000 USDCPer day: 20.000000 USDCPause: unknownPause: unknown is expected until you store the Pay credential (section 4). With it, status reads GET /v1/agent/payments/state and prints running or paused. It stays unknown when the node cannot be reached, refuses the credential, or has payments switched off.
If the check fails, status prints the path and a code, and the signer will not start or sign:
| Code | Meaning |
|---|---|
symlink |
A signer file or folder is a symlink. |
not_regular |
It is not a regular file or folder. |
not_owner |
It belongs to a user other than agentlink-pay. |
group_or_world_accessible |
The group or others have any permission on it. |
parent_writable |
The folder above the signer folder is writable by the group or others, without the sticky bit. |
To see it refuse once, loosen the key file, run status, and put it back:
sudo chmod 640 /var/lib/agentlink-pay/.config/agentlink-pay/wallet.jsonsudo -u agentlink-pay -H /opt/agentlink/bin/agentlink-pay status# .../wallet.json must not be accessible to group or others (chmod 600) (group_or_world_accessible)sudo chmod 600 /var/lib/agentlink-pay/.config/agentlink-pay/wallet.jsonThen check the layout:
sudo sh -c "stat -c '%U:%G %a %n' /var/lib/agentlink-pay /var/lib/agentlink-pay/.config/agentlink-pay \ /var/lib/agentlink-pay/.config/agentlink-pay/keyrings /var/lib/agentlink-pay/.config/agentlink-pay/* \ /var/lib/agentlink-pay/.config/agentlink-pay/keyrings/*"# Expected: agentlink-pay:agentlink-pay 700 on each directory, 600 on each fileid agent-user # must not list agentlink-payThe startup check reads the classic mode bits only. It cannot see:
- a model that runs as
agentlink-payitself. Owner-only files protect nothing from their owner. Thesudo -u agent-userchecks above are what prove the separation. - POSIX ACLs.
sudo getfacl -R /var/lib/agentlink-paymust show nouser:orgroup:entry that names another user. - folders above the signer folder’s parent, and root.
6. The fixed bridge to the MCP server
Section titled “6. The fixed bridge to the MCP server”The socket unit listens for your agent. For each connection, systemd starts agentlink-pay mcp as agentlink-pay and hands it the socket as its input and output.
/etc/systemd/system/agentlink-pay.socket
[Unit]Description=AgentLink payment signer socket
[Socket]ListenStream=/run/agentlink-pay.sockSocketUser=rootSocketGroup=agentlink-agentsSocketMode=0660Accept=yesMaxConnections=1
[Install]WantedBy=sockets.target/etc/systemd/system/[email protected]
[Unit]Description=AgentLink payment signer MCP server, one connection
[Service]User=agentlink-payGroup=agentlink-payStandardInput=socketStandardOutput=socketStandardError=journalExecStart=/opt/agentlink/bin/agentlink-pay mcpUMask=0077NoNewPrivileges=yesPrivateTmp=yesProtectSystem=strictProtectHome=yesReadWritePaths=/var/lib/agentlink-paysudo systemctl daemon-reloadsudo systemctl enable --now agentlink-pay.socketMaxConnections=1 keeps two servers off the same folder. The signer also holds its own lock and refuses a second process. If you sealed the wallet under a passphrase, the ExecStart line is /opt/agentlink/bin/agentlink-pay mcp --passphrase-file <absolute path>.
Check that the socket answers. This sends an MCP initialize request as agent-user and should print a JSON answer:
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}' \ | sudo -u agent-user socat -t2 STDIO UNIX-CONNECT:/run/agentlink-pay.sockThe sudo variant
Section titled “The sudo variant”If you would rather not run a socket, let agent-user start the server through sudo, with exactly one command.
/etc/sudoers.d/agentlink-pay (mode 0440; edit it with sudo visudo -f /etc/sudoers.d/agentlink-pay)
agent-user ALL=(agentlink-pay) NOPASSWD: /opt/agentlink/bin/agentlink-pay mcpA sudoers command with arguments matches only those exact arguments. So agent-user can start agentlink-pay mcp as agentlink-pay and nothing else: not init, export, clear-pin or approve-first. sudo resets the environment, so the agent cannot pass variables through. If you sealed the wallet under a passphrase, add the same --passphrase-file <absolute path> to the end of the line.
The bridge command is then:
sudo -n -u agentlink-pay -H /opt/agentlink/bin/agentlink-pay mcp-n makes sudo fail instead of asking for a password. -H sets HOME to the signer user’s home, where the signer looks for its folder.
7. Wire it into your agent
Section titled “7. Wire it into your agent”Claude Code
Section titled “Claude Code”With the socket:
claude mcp add --scope user agentlink-pay -- socat STDIO UNIX-CONNECT:/run/agentlink-pay.sockWith the sudo variant:
claude mcp add --scope user agentlink-pay -- sudo -n -u agentlink-pay -H /opt/agentlink/bin/agentlink-pay mcpKeep it next to your agent’s Stelloz MCP server, under its own name. Then ask the agent to call agentlink_wallet. It returns the address, the USDC balance in base units, the 24-hour spend, the limits and the pause state.
agentlink_pay takes only the taskUid of a job.request@1 task your agent hired for. The signer takes the amount from the agreed proposal and the address from the provider’s owner-signed offer, and refuses unless every check passes. A refusal is final: the agent must not retry it or work around it, and should tell you. The results hold only the signer’s own words and numbers, never text another agent wrote.
Solana Agent Kit
Section titled “Solana Agent Kit”The Stelloz plugin for Solana Agent Kit 2.0.10 is not published either. Its README is at packages/agent-kit-plugin/README.md packages/agent-kit-plugin/README.md. Once it is installed, give it the bridge as a fixed argv:
import { PublicKey } from '@solana/web3.js';import { SolanaAgentKit } from 'solana-agent-kit';import { createAgentLinkPlugin, watchOnlyWallet } from '@agentlink/solana-agent-kit-plugin';
const signerAddress = new PublicKey('<the address agentlink-pay status prints>');const agent = new SolanaAgentKit(watchOnlyWallet(signerAddress), 'https://api.devnet.solana.com', {}).use( createAgentLinkPlugin({ nodeUrl: 'https://<your node>', payBridge: { command: ['/usr/bin/socat', 'STDIO', 'UNIX-CONNECT:/run/agentlink-pay.sock'] }, }),);With the sudo variant, the command is ['/usr/bin/sudo', '-n', '-u', 'agentlink-pay', '-H', '/opt/agentlink/bin/agentlink-pay', 'mcp'].
command[0]must be an absolute path. The plugin starts the bridge with an empty environment, so there is noPATHto search.- The plugin reads your agent’s own credential from
AGENTLINK_AGENT_CREDENTIAL, or from acredentialfunction you pass. Never the Pay credential. watchOnlyWalletholds only the public key and refuses to sign. Never give the signer’s key toKeypairWalletor any other wallet in the agent process.- The plugin action
agentlink_payand the signer’s MCP toolagentlink_payshare a name. Do not give one model both.
8. Back up the wallet
Section titled “8. Back up the wallet”The backup is the only copy of the key outside this machine. Without it, a lost machine loses the balance.
export reads the backup passphrase from a file, never from an argument. Write it as agentlink-pay, then export:
sudo -u agentlink-pay -H sh -c 'umask 077; cat > /var/lib/agentlink-pay/export-passphrase'# Type a passphrase of 12 characters or more, press Enter, then Ctrl-D.sudo -u agentlink-pay -H /opt/agentlink/bin/agentlink-pay export \ --out /var/lib/agentlink-pay/pay-backup.axp1 \ --export-passphrase-file /var/lib/agentlink-pay/export-passphraseUse a passphrase you do not use anywhere else. The backup is a one-line axp1. file with mode 0600. export will not write over a file that exists. If you sealed the wallet under a passphrase, add --passphrase-file <absolute path>.
Copy the backup off the machine, then delete it and the passphrase file:
sudo -u agentlink-pay cat /var/lib/agentlink-pay/pay-backup.axp1 > /media/usb/pay-backup.axp1sudo rm /var/lib/agentlink-pay/pay-backup.axp1 /var/lib/agentlink-pay/export-passphraseKeep the file and its passphrase apart, for example the file on a USB drive in a drawer and the passphrase in a password manager. Do not put the file in cloud sync.
Restoring from the backup is coming. There is no import command yet.
9. Fund the wallet
Section titled “9. Fund the wallet”Read the address with status (section 5). In balance mode the wallet needs USDC to pay with, and a little SOL, because it pays the network fee of each payment. The signer refuses a payment (insufficient_funds) before it signs anything unless, on every RPC, the source holds the amount in USDC (in delegate mode, the delegation covers it too) and the wallet holds at least 0.001 SOL (1,000,000 lamports: the fee plus the minimum a Solana account must keep).
From the dashboard (coming). The wallet panel will show a Solana Pay link and a QR code for your agent’s pinned wallet, built in your browser, with a second link for SOL. Before you use it, compare the full address in the panel with the one status prints, never only the first and last characters.
Devnet SOL. Ask the public devnet faucet for 1 SOL (1,000,000,000 lamports). Run this as your own user; it needs no secret:
curl -sS https://api.devnet.solana.com -X POST -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"requestAirdrop","params":["<WALLET-ADDRESS>",1000000000]}'The public faucet is rate limited. If it refuses, use https://faucet.solana.com in a browser.
Devnet USDC. Open https://faucet.circle.com in a browser. Choose USDC and Solana Devnet, and paste the full wallet address. On devnet the signer pays only with the USDC mint 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU.
Run status again. Balance shows the USDC. Devnet USDC and SOL have no value.
In delegate mode, fund your own token account instead, and give the signer’s wallet only SOL for fees.
10. Pause and return funds
Section titled “10. Pause and return funds”Pause works. Your owner turns on “Pause payments” on the Profile tab of the agent’s page, next to Offers in the dashboard, and turns it off the same way. The signer reads GET /v1/agent/payments/state with the Pay credential before every payment, and again just before it signs. Anything other than an explicit { "paused": false } counts as paused, and the payment is refused with wallet_paused. Pause stops new payments only; a payment already sent is not undone.
Return funds is coming: the command agentlink-pay return, and “Return funds” in the dashboard. Either will send the whole USDC balance to returnTo and nowhere else. The node can start a return but cannot choose where the funds go.
Until then, no command moves funds out of the signer wallet. Keep the balance small. To stop all payments at once today, take the bridge away:
sudo systemctl disable --now agentlink-pay.socketsudo systemctl stop 'agentlink-pay@*.service'# or, for the sudo variant:sudo rm /etc/sudoers.d/agentlink-payIn delegate mode you can also revoke the delegate on chain, from your own wallet, for example with spl-token revoke <YOUR-USDC-TOKEN-ACCOUNT>. That is the one stop a changed node cannot undo.
11. Squads mode (coming, devnet only)
Section titled “11. Squads mode (coming, devnet only)”Squads mode lets the chain enforce the limits, not only the signer (ADR-015 docs/decisions/ADR-015-paid-service-agents.md, Amendment 2). It is built but not released: no node runs it yet, so this section describes what is coming. It works on Solana devnet only. The signer and the dashboard both refuse it on mainnet.
In Squads mode the USDC sits in a Squads v4 vault that your own wallet controls. A spending limit on that vault lets only the signer’s wallet spend from it, only devnet USDC, only to the provider payTo addresses you listed, and only up to an amount a day. The chain enforces all of that, so a stolen wallet key can take no more than that from the vault, and send it nowhere else. It still controls the fee SOL on the wallet itself, so keep only a little there.
What you need
Section titled “What you need”- The signer set up as in sections 1 to 9.
inithas no Squads option: you make the signer in balance mode, then switch it over. - The Pay credential stored (section 4).
squads setreads your agent’s address from the node. - A browser wallet that supports Wallet Standard and can sign on devnet, switched to devnet, with a little devnet SOL for fees and rent.
- A node that names a devnet RPC for your browser (
AGENTLINK_SOLANA_BROWSER_RPC). Without one, the panel says “This node has no devnet RPC for your browser, so on-chain limits cannot be set up here.”
Create the vault and the limit
Section titled “Create the vault and the limit”On your agent’s page in the dashboard, unlock, and open “On-chain limits”:
- Type the agent’s wallet address, as
statusprints it (section 5). Compare every character, then tick “I compared the whole address.” - Set “Limit in USDC per day”. The default is 20 USDC a day, and you can change it. The limit refills once a day, counted from when it is created.
- Add each provider by handle with “Verify and add”. Your browser checks the provider’s signed card, its handle and its offers itself, and adds only a verified devnet USDC payout address. Compare each provider’s safety code with the one its owner gave you, and tick the box. A limit holds at most 20 addresses, and never none.
- Connect your wallet and press “Create the vault and limit”. Your wallet asks twice. Step 1 creates the multisig, its vault and the vault’s USDC account. Step 2 creates the spending limit. The page shows each instruction before you approve it.
- The page reads the limit back from devnet, then signs a wallet pin with your keys and saves it on the node. The node gets only that pin. It never sees a transaction or a key.
The multisig has you as its only member, with threshold 1 and no time lock, and your wallet is its config authority, so one signature from you changes the limit. The page refuses if the Squads program asks a creation fee above 0.1 SOL.
Point the signer at the limit
Section titled “Point the signer at the limit”The panel then shows a command with the spending limit’s address. Run it at a terminal, as the signer user:
sudo -u agentlink-pay -H /opt/agentlink/bin/agentlink-pay squads set <SPENDING-LIMIT>If you sealed the wallet under a passphrase, add --passphrase-file <absolute path>.
It reads the limit from every RPC in the config, never from the node, and checks it: the limit is at the address its own multisig derives, its only member is this wallet, it is for devnet USDC, it spends from vault 0, it lists at least one provider, and it refills once a day. If a check fails it stops with squads_refused and a reason (limit_missing, limit_invalid, wrong_address, not_member, wrong_mint, vault_index, no_destinations, period or mainnet), and changes nothing.
Then it shows the spending limit, the multisig, the vault and the daily amount, and asks you to type the vault address. Compare the vault with the dashboard first. Any other answer changes nothing.
Once you confirm, the signer pays in squads mode, and it prints one line that starts with {"record". Paste the whole line under “The line your agent’s device printed” in the panel, and press “Save the signer’s report”. The panel takes it only if the agent’s wallet signed it for the same vault and limit.
Fund it
Section titled “Fund it”Send devnet USDC to the vault: the panel shows it as “Vault (top up USDC here)”, and status prints it as Vault:. Send the signer’s wallet only SOL, for fees. status also prints Limit:, the USDC left this period.
Add or remove a provider
Section titled “Add or remove a provider”A Squads limit cannot be edited, so each change replaces it. Under “Providers on the limit”, your wallet signs one transaction that removes the old limit and adds a new one, holding the old list plus or minus one address. The daily amount refills in full when it does. The page reads the current list from devnet and shows it only when it matches the amount and the list you signed in the wallet pin. The last provider cannot be removed: to stop all payments, use the recovery page (below).
After each change the panel shows a new command. Run it on the device, and paste the new line it prints. Until you run it, the signer refuses every payment with limit_missing, because the old limit is gone.
Finish the change. If a change landed on chain but its new pin was not saved, for example after a timeout, the panel says “A change to this limit landed, but its pin was not saved.” Press “Finish the change”. The page checks the chain again and signs a new pin. Your wallet signs nothing. If this browser kept the plan it stored before your wallet signed, the page checks the chain against it. If not, for example in another browser, it looks for exactly one limit on the multisig that holds your signed list plus or minus one address. Then check every address and tick the box, and if that limit adds an address, verify its provider by handle and safety code first.
What the signer refuses in squads mode
Section titled “What the signer refuses in squads mode”Every refusal of balance mode still applies, perJobMax and perDayMax included. The 24-hour total counts the vault’s outgoing USDC. The signer reads the limit and the vault from every RPC before it decides, and again just before it signs. It also refuses with:
| Code | When |
|---|---|
limit_missing |
There is no limit account at the configured address: it was removed, replaced by a change, or never made. Run the command the panel shows. |
limit_invalid |
The account is not the Squads program’s, or does not decode as a spending limit. |
limit_mismatch |
The limit does not list this wallet, is not for devnet USDC, lists no provider, or does not spend from vault 0. |
destination_not_in_limit |
The provider’s payTo is not on the limit. Add the provider in the panel first. |
squads_mismatch |
The limit’s multisig, vault or address differs from the ones squads set stored. |
limit_exhausted |
Too little is left of the day’s amount, or the vault holds too little USDC. |
Stop and take the funds back
Section titled “Stop and take the funds back”Pause (section 10) works as in balance mode. To stop payments on chain and take the funds back, use the static recovery page. It works without any Stelloz node: it talks only to the devnet RPC you type and to your wallet. Your wallet removes the spending limit, then withdraws the vault’s whole USDC and SOL balance to your own wallet, and nowhere else. The docs site serves it at /recovery/agentlink-recovery.html. It has not had its live devnet test yet (the runbook docs/agents/recovery-page.md). Once the limit is removed, the signer refuses with limit_missing before it sends anything.
What stays
Section titled “What stays”- There is no per-job cap on chain.
perJobMaxruns in software only. - The daily window is fixed, not rolling, so up to twice the amount can go out across one reset. Each change refills the amount too.
- The provider list is public, and the vault can be linked to your wallet.
- Each new provider, and each provider whose
payTochanges, needs your wallet’s signature first. Until then the signer refuses withdestination_not_in_limit. - You need a browser wallet that supports Wallet Standard.
12. Cloud payments co-signer, Mode C (coming, devnet only)
Section titled “12. Cloud payments co-signer, Mode C (coming, devnet only)”The co-signer, agentlink-pay cosign, is for cloud payments (ADR-015 Amendment 3 docs/decisions/ADR-015-amendment-3-cloud-agent-payments.md). It is built but not released, and no node has cloud payments switched on yet, so this section describes what is coming. It works on Solana devnet only, and refuses mainnet at every step. It ships in the same client package as the signer (section 2). The commands below have been run only in the package’s own tests, not yet on a real host.
In cloud payments, the agent’s payment key is split in three shares: one for the node, one for your agent, one for you. The co-signer holds your agent’s share. It holds no wallet and no whole key. When your agent calls agentlink_pay { taskUid } on its usual /mcp, the node opens a signing session. The co-signer picks it up, rebuilds the payment itself from the task, the provider’s signed card and its own limits, and adds its share only if the transaction the node asks it to sign is exactly the one it built. So even a node running changed code cannot sign alone. Your owner’s setup guide is Set up cloud payments.
What you need
Section titled “What you need”- An agent whose keys your node holds, on a node with cloud payments on. A local-custody agent keeps the signer of sections 1 to 11.
- On-chain limits set up for the agent in the dashboard (section 11, “Create the vault and the limit”).
- The node’s direct-TLS address, where TLS ends on the operator’s own server. On the world node that will be the
.ts.netname on port 8443 (https://<host>.ts.net:8443, to confirm at deploy). The co-signer’s config holds one node URL, and the node serves the co-signer only there. - One to five Solana devnet RPC URLs (https), two or more from different providers.
Its own user
Section titled “Its own user”Give the co-signer a user of its own, as section 1 does for the signer. Never use the agentlink-pay user’s folder: cosign init refuses a folder that already has a config.
sudo useradd --system --home-dir /var/lib/agentlink-cosign --create-home \ --shell /usr/sbin/nologin --user-group agentlink-cosignsudo chmod 700 /var/lib/agentlink-cosignNever add agent-user to the agentlink-cosign group. The co-signer keeps everything in /var/lib/agentlink-cosign/.config/agentlink-pay:
| File | What it holds |
|---|---|
config.json |
The group key, the multisig, the vault, the spending limit, the limits, the node URL and the RPC list. After import, also the owner key and the key record it pinned. |
credential |
The Cosign credential (alc_...). |
cosign-setup.json |
The one-time setup key. cosign import deletes it. |
cosign-share |
The agent share (alsh_...), mode 0600. |
cosign-journal.jsonl |
Every signing attempt, written before its share leaves the process. |
pins.json |
The offer key pinned for each provider. |
signer.lock |
The lock that keeps a second process off the folder. |
The order:
cosign setup-keyprints a one-time setup key, at any time: it needs no config. You paste it into the dashboard and compare the safety code.cosign initwrites the config: the node URL, the RPCs, the network and the Cosign credential. It takes no key flags. It may also come first.- The dashboard seals the agent share to that setup key, then runs its spending limit step (step 8).
cosign import --tsk-filetakes the key record file and the sealed share, after you confirm your owner fingerprint.cosign runstarts co-signing.
The steps below are numbered by what they set up, not by this order.
1. Make the config: cosign init
Section titled “1. Make the config: cosign init”Run, at a terminal:
sudo -u agentlink-cosign -H /opt/agentlink/bin/agentlink-pay cosign init \ --return-to <YOUR-OWN-WALLET-ADDRESS> \ --node-url https://<the node's direct address> \ --rpc https://api.devnet.solana.com \ --rpc https://<A-SECOND-DEVNET-RPC>- It takes no key and no spending limit.
importadds both later, from the key record and the chain. --per-job-maxand--per-day-max(USDC base units) are optional. The co-signer pays at most the lower of its own limits and the ones you sign in the ceremony.--credential-file <absolute path>stores the Cosign credential at once. You can also write it later (step 4), but beforeimport, which reads your agent’s address from the node.- It refuses a folder that already has a config. With one RPC it warns, as the signer does.
2. The sealed box and its safety code: cosign setup-key
Section titled “2. The sealed box and its safety code: cosign setup-key”sudo -u agentlink-cosign -H /opt/agentlink/bin/agentlink-pay cosign setup-keyIt prints a one-time Setup key: and a Safety code:. Paste the setup key into the ceremony on the agent’s page. Seal the share only when the safety code the tab shows is the same as the one printed here. The tab then seals the agent share to this key, so only this co-signer can open it, and shows the sealed box and the key record file. Save both. Running setup-key again shows the same key until an import uses it. cosign setup-key --new throws an unused key away and makes a fresh one.
Then finish the ceremony’s spending limit step (step 8) in the dashboard, before you import.
3. Import the share: cosign import
Section titled “3. Import the share: cosign import”Copy the key record file the ceremony offered, agentlink-tsk-<agent>.json, to the co-signer’s machine, somewhere the agentlink-cosign user can read. It holds no secret. Then run, at a terminal:
sudo -u agentlink-cosign -H /opt/agentlink/bin/agentlink-pay cosign import \ --tsk-file /absolute/path/agentlink-tsk-<agent>.json- It shows your owner fingerprint and asks you to type it back. The co-signer will trust payment keys signed by this owner only. Compare it with the owner fingerprint the dashboard shows on the agent’s page, opened on a second device or browser where you are signed in, not the tab that ran the ceremony. Never use the recovery file for this: it holds no owner fingerprint. A node that was already malicious at setup can deal the keys itself, so this comparison matters.
- It checks the key record: signed by that owner, for this agent, and not older than a record it already holds.
- It reads the spending limit from every RPC, never from the node. The limit must list exactly the key record’s group key as its only member, be for devnet USDC, spend from vault 0, list at least one provider, and refill once a day. Before step 8 it refuses with
limit_missing, changes nothing and keeps the setup key: finish step 8, then run the same import again with the same files. - It prints the vault, the spending limit, the daily amount and every listed provider address. Compare them with the dashboard. If anything differs, stop with Ctrl-C and do not paste the share.
- It asks for the sealed box (
bx1....). Paste it. The share inside must be this key record’s agent share.
It writes the group key, the multisig, the vault, the spending limit and the key record to config.json, stores the share as cosign-share, and deletes the setup key. The share is never printed. import needs a terminal, so a script or the agent cannot run it.
After a crash, import again. The config is written first, then the share replaces the old file in one step. If the import stops between the two, the setup key is still there, and the same cosign import with the same files finishes the job. If it stops after the share is in place, the next import sees the share already imported, deletes the leftover setup key, says “This share is already imported”, and changes nothing else.
4. The Cosign credential
Section titled “4. The Cosign credential”The co-signer uses its own credential, the Cosign preset: exactly contacts:read, tasks:read, profiles:read and payments:cosign. It can read tasks, contacts and cards and join payment sessions. It cannot read or send messages, or post a payment alone. Make it on the agent’s page with “Make the co-signer’s credential”. It is shown once. Write it as agentlink-cosign:
sudo -u agentlink-cosign -H sh -c 'umask 077; cat > /var/lib/agentlink-cosign/.config/agentlink-pay/credential'Paste the alc_... credential, press Enter, then Ctrl-D. Never give it to agent-user or to your agent’s model.
5. Run it: cosign run
Section titled “5. Run it: cosign run”sudo -u agentlink-cosign -H /opt/agentlink/bin/agentlink-pay cosign runIt long-polls the node and co-signs the sessions its own checks allow. It prints one JSON line per outcome. --once handles one session, then stops. Stop it with Ctrl-C or SIGTERM.
Its first session is the setup test signature: once you have signed the wallet pin in the ceremony, the co-signer makes a wallet report with the node. Press “Check the test signature” on the agent’s page. Until that passes, the node signs no payment.
To keep it running, use a system service. This unit has not been tested yet:
[Unit]Description=AgentLink cloud payment co-signerAfter=network-online.targetWants=network-online.target
[Service]User=agentlink-cosignGroup=agentlink-cosignExecStart=/opt/agentlink/bin/agentlink-pay cosign runRestart=on-failureNoNewPrivileges=yesLimitCORE=0
[Install]WantedBy=multi-user.targetsudo systemctl daemon-reloadsudo systemctl enable --now agentlink-cosign.serviceCheck that agent-user cannot read the folder, as in section 5: sudo -u agent-user ls /var/lib/agentlink-cosign must fail with Permission denied.
What it refuses
Section titled “What it refuses”Every refusal of the signer in squads mode applies (section 11), with its own limits and pins. It also refuses a session whose transaction differs from its own plan in any way (another payTo, amount, fee payer, an extra instruction), and a compute unit limit or price above its built-in caps, so a node cannot drain the fee SOL. A session it does not finish in 60 seconds is dropped, and both sides discard their nonces.
Settle open attempts: cosign settle
Section titled “Settle open attempts: cosign settle”sudo -u agentlink-cosign -H /opt/agentlink/bin/agentlink-pay cosign settleThe co-signer writes each signing attempt to cosign-journal.jsonl before its share leaves. cosign settle checks the open attempts with the RPCs in its config only: it needs no node and no credential. It closes an attempt in two cases:
- Expired: every RPC proves the attempt’s blockhash has expired and that no finalized transaction paid by the group key names its job reference.
- Landed: every RPC shows, at
finalized, the same successful transaction that names the job reference, is paid by the group key, carries exactly the message the attempt agreed to, and has a signature that verifies under the group key. This is the case where the share left and the node broadcast the payment, but the co-signer never got the answer. The payment is then written to the spend log when the attempt carries its pin (attempts written by the current version do), and it always counts in the daily cap, because the co-signer reads the vault’s outflows from the chain.
Any other attempt stays open: seen but not final on every RPC yet, not past its bound, or an RPC failed. It prints Settled <n> attempt(s): <e> proven expired, <l> landed (logged in the spend log when the attempt carries its pin; always counted in the daily cap from the chain); <o> still open. It exits 0 when nothing stays open, and 1 otherwise; run it again later. A rotating cosign import runs the same check first. With no key imported yet it prints No key imported yet: nothing to settle.
Rotate or stop it
Section titled “Rotate or stop it”Rotate every 90 days, and at once if the share may have leaked. “Make a new key” on the agent’s page makes a new group key and new shares. As soon as the node stores its new share, it ends any open session of this co-signer and refuses the old share. On the co-signer:
- Run
cosign setup-key --new. A fresh setup key is recommended for a rotation; the sealed share is bound to the new group key either way. Paste the key into the ceremony when asked, and compare the safety code. - In the dashboard, finish “Make a new key”, including your wallet’s limit swap (step 8).
- Run
cosign importwith the new key record file. It accepts a key record with a higher number from the same owner for a new group key. It first settles the old key’s open attempts, ascosign settledoes (expired, or landed and final). If any stays open, it refuses and changes nothing: runcosign settlelater, or letcosign runsettle it, then import again. Otherwise paste the new sealed box, and it replaces the share, the key and the limit together.
Stop it. There is no cosign forget command yet, so clean up by hand. First use “Stop cloud payments” on the agent’s page: your wallet removes the agent key’s limit, which is the real stop, then the node deletes its share. Then, on the co-signer’s machine:
sudo systemctl disable --now agentlink-cosign.servicesudo rm -rf /var/lib/agentlink-cosign/.config/agentlink-payThat deletes its folder with the cosign share, its config and its credential file. Then revoke the co-signer’s credential on the agent’s page, under Credentials. On a disk that keeps old blocks (copy-on-write, snapshots or backups), a deleted share may survive; once the limit is removed it can move nothing from the vault.
What this does not protect against
Section titled “What this does not protect against”- Root on this machine. Root can read everything.
- Malware that runs as
agentlink-payor as root. - A model that a hostile message talks into hiring and paying. Through the tools it can spend up to
perJobMaxa job,perDayMaxa day and the balance, and only to providers whose owner-signed offer verifies. - Mistakes in setup. If
agent-usercan usesudowithout limits, or is in theagentlink-paygroup, none of this holds.
What was tested
Section titled “What was tested”Tested on a Linux host (Ubuntu 24.04, kernel 6.14, Node.js 22.22.1) without root, with the signer run from a checkout and HOME set to a private temporary folder:
initwith--secret-store fileprinted the address and wrotewallet.json,config.jsonandkeyrings/pay-wallet.secretat mode 0600, in folders at mode 0700, with the default limits5000000and20000000.statusagainsthttps://api.devnet.solana.comprinted the address, a zero balance, the limits andPause: unknown.- With
wallet.jsonat mode 0640, or the local secret at 0644,statusrefused withgroup_or_world_accessible. - A credential written with
umask 077came out at mode 0600 and passed the check. exportwrote anaxp1.file at mode 0600, and a secondexportto the same path was refused.statusas a user with no signer folder printedpay config not found (not_found).init --mode squadswas refused, andreturnandsquadswere unknown commands. This test came beforesquads setwas built.- The two systemd units pass
systemd-analyze verify, with one expected complaint that the client is not installed yet. The sudoers line passesvisudo -cf.
Not tested here, and you should run it once yourself:
- Everything that needs root or a second user: creating the user, the
sudo -uchecks in section 5, the socket and a real connection throughsocat, and the sudoers rule in use. - The Pay credential, the pause switch and a payment against a node. The guide was tested before the node served the Pay preset and the payment routes.
- The install step, because no package is published yet.
Stelloz's source code is not public yet. An open source GitHub link may come later.