Spendesk for AI agents — issue virtual cards to your agents, set per-agent spending policies (amount, interval, allowed merchants), and approve or decline their purchase authorizations from the CLI.
Built on Stripe Issuing with a Bubble Tea TUI. Every command also runs non-interactively so you can script it.
- Go 1.24+
- A Stripe account with Issuing enabled (test mode works out of the box)
- A Stripe secret key (
sk_test_…for test mode,sk_live_…for production) - Your Stripe Issuing program must support EUR cards — the issuing currency is hardcoded to
eur - Default cardholder identity (first/last/DOB/address) is configured during
agentdesk setupand reused for every new agent
One-liner (Linux amd64/arm64):
curl -sSL https://raw.githubusercontent.com/xdamman/agentdesk/main/install.sh | bashInstalls the latest release into /usr/local/bin (or ~/.local/bin if that isn't writable). Re-run the same command to update. Set AGENTDESK_VERSION=v0.1.0 to pin a specific tag, or AGENTDESK_PREFIX=/somewhere/else/bin to override the install dir.
From source:
git clone https://github.com/xdamman/agentdesk.git
cd agentdesk
go build -o agentdesk .
sudo mv agentdesk /usr/local/bin/ # optionalReleases are built by .github/workflows/release.yml on every tag matching v*, publishing agentdesk-linux-amd64, agentdesk-linux-arm64, and a SHA-256 checksums.txt.
All state lives under ~/.agentdesk/:
~/.agentdesk/
├── config.json # Stripe API key, default cardholder, admin npub, webhook + daemon config
├── agents.json # registry of agents → Stripe card IDs + local policy
├── rules.json # auto-approve rules used by the daemon
├── nsec # daemon's Nostr secret key (hex, 0600 — never share)
└── <agent-name>/
└── card # card PAN, CVC, expiry, policy (0600 perms)
Files are created with 0600 / 0700 permissions. Delete the directory to fully reset.
Every command supports an interactive (TUI) and a non-interactive mode. The non-interactive mode activates when you pass the relevant flags, or when stdout is not a terminal (e.g. piped to jq).
Configure your Stripe API key and the default cardholder identity + billing address used when new agents are created. Stripe Issuing requires a real person's KYC details (first name, last name, date of birth, address) per cardholder — agentdesk reuses these defaults so that only the agent name varies per card.
Interactive behaviour:
- First run (no config): prompts for the Stripe key, then opens the billing form.
- Subsequent runs: prints the current settings and shows a picker — choose Stripe API key, Billing & identity, or Admin approver (Nostr) to edit that slice only. Existing settings are preserved if you don't touch them.
- Alternative: start
agentdesk daemonand visit http://localhost:4242/setup in your browser. It serves the same form over HTTP. Saving is restricted to loopback requests, so exposing the daemon publicly (e.g. via ngrok) does not let strangers rewrite your Stripe key.
Non-interactive flags — pass any subset; unspecified flags leave existing values alone:
| Flag | Env | Description |
|---|---|---|
--api-key <key> |
STRIPE_API_KEY (first-run only) |
Stripe secret key |
--first-name <name> |
Cardholder first name | |
--last-name <name> |
Cardholder last name | |
--dob <YYYY-MM-DD> |
Date of birth | |
--email <email> |
Cardholder email (optional; defaults to <agent>@agentdesk.local) |
|
--phone <+e164> |
Phone number in E.164 format | |
--address-line1 <...> |
Street address line 1 | |
--address-line2 <...> |
Street address line 2 | |
--city <...> |
City | |
--state <...> |
State / region | |
--postal-code <...> |
Postal code | |
--country <XX> |
ISO 3166-1 alpha-2 country (e.g. FR) |
|
--admin <id> |
Admin Nostr identity for approval prompts. Accepts npub1…, a 64-char hex pubkey, or a NIP-05 address ([email protected]). Pass --admin "" to clear. |
|
--show |
Print current settings and exit |
# fully interactive
agentdesk setup
# show current settings
agentdesk setup --show
# one-shot API key
agentdesk setup --api-key sk_test_xxx
# one-shot admin (NIP-05 or npub)
agentdesk setup --admin [email protected]
agentdesk setup --admin npub1abc...
# one-shot billing details
agentdesk setup \
--first-name Ada --last-name Lovelace --dob 1815-12-10 \
--address-line1 "8 rue de Londres" --city Paris \
--postal-code 75009 --country FR
# complete setup in one go
STRIPE_API_KEY=sk_test_xxx agentdesk setup \
--first-name Ada --last-name Lovelace --dob 1815-12-10 \
--address-line1 "8 rue de Londres" --city Paris \
--postal-code 75009 --country FRList every Issuing card on the Stripe account, joined with the local agent registry.
| Flag | Description |
|---|---|
--json |
Emit JSON (array of card objects) instead of the TUI table. |
agentdesk cards
agentdesk cards --json | jq '.[] | {agent, last4, status}'List agents with their policy, amount spent this period, and remaining allowance. Spend is computed by summing approved authorizations for the card within the current daily/weekly/monthly window.
| Flag | Description |
|---|---|
--json |
Emit JSON. |
agentdesk agents
agentdesk agents --jsonCreate a new cardholder and issue a virtual card. The revealed card details are printed once and persisted to ~/.agentdesk/<agent-name>/card.
| Flag | Required | Description |
|---|---|---|
--name <name> |
Triggers non-interactive mode | Agent name. [a-zA-Z0-9_-]{2,32} |
--allowance <eur> |
With --name |
EUR amount, e.g. 100 or 100.00 |
--interval <d|w|m> |
Optional (default monthly) |
daily / weekly / monthly |
--merchants <list> |
Optional | Comma-separated merchant hints (stored in card metadata) |
--json |
Optional | Print the created agent + revealed card as JSON |
# interactive
agentdesk agents add
# non-interactive
agentdesk agents add \
--name research-agent \
--allowance 250 \
--interval monthly \
--merchants openai.com,anthropic.com
# scriptable
agentdesk agents add --name bot --allowance 50 --json \
| jq -r '.number'Edit an existing agent's allowance, interval, or allowed merchants. Passing any of --allowance, --interval, --merchants runs non-interactively and requires the name as a positional arg. Unset flags preserve the existing value.
| Flag | Description |
|---|---|
--allowance <eur> |
New allowance in EUR |
--interval <d|w|m> |
New interval |
--merchants <list> |
New allowed-merchants list. Pass --merchants "" to clear. |
# interactive (picker → form)
agentdesk agents edit
# non-interactive
agentdesk agents edit research-agent --allowance 500
agentdesk agents edit research-agent --interval weekly --merchants openai.com
agentdesk agents edit research-agent --merchants "" # clear listCancel the agent's Stripe card and delete local state. Pass a positional name for non-interactive use; otherwise a TUI picker is shown.
agentdesk agents rm research-agent # non-interactive
agentdesk agents rm # pickerList the latest Stripe Issuing authorizations (purchase attempts from agent cards), across all agents.
| Flag | Description |
|---|---|
--json |
Emit JSON. |
--limit <n> |
Max number of requests (default 25). |
agentdesk requests
agentdesk requests --limit 100 --json | jq '.[] | select(.status=="pending")'Show detailed info for a single authorization: amount, agent, vendor, category, location, timestamp.
| Flag | Description |
|---|---|
--json |
Emit JSON instead of the formatted view. |
agentdesk requests show iauth_1Abc123
agentdesk requests show iauth_1Abc123 --jsonApprove or decline an authorization.
Stripe's issuing_authorization.request webhook holds an authorization in pending state for 2 seconds. If you call approve after the window has closed, the call fails with a 400. When that happens, agentdesk automatically saves an auto-approve rule keyed on card + merchant + amount + date so that the next identical request is auto-approved by the daemon within the window.
| Flag | Description |
|---|---|
--json |
Emit the resulting authorization (or rule-save outcome) as JSON. |
agentdesk requests approve iauth_1Abc123
agentdesk requests decline iauth_1Abc123 --jsonExample expired-approve output:
⚠ iauth_1Abc123 is expired — Stripe's 2s window is closed.
Saved rule rule_17292931234: auto-approve €12.50 @ openai.com on 2026-04-18
Run `agentdesk daemon` to catch the next matching request in time.
List or remove auto-approve rules. Rules are created by the approve-on-expired flow above; the daemon consults them on each issuing_authorization.request.
| Flag | Description |
|---|---|
--json |
Emit rules as JSON (non-interactive). |
agentdesk rules
agentdesk rules --json | jq '.[] | {id, merchant, amount}'
agentdesk rules rm rule_17292931234A long-running process that does four things in parallel:
- Stripe webhook listener — receives
issuing_authorization.requestevents and auto-approves the ones matching a saved rule within the 2-second Stripe window. - Homepage — serves
GET /with the daemon's Nostrnpub(plus a QR code), the list of agents, and recent requests. Visithttp://localhost:4242/while the daemon runs. - Nostr NIP-04 listener — accepts DMs from agents requesting virtual cards. See SKILL.md for the agent-side protocol.
- Admin approver over Nostr — when a request arrives that doesn't match an auto-approve rule, the daemon DMs the admin (configured via
agentdesk setup --admin) with agent/merchant/amount. The admin repliesapprove/yes/ok/👍(case-insensitive) to approve, ordecline/no/👎to decline. If multiple pending asks stack up, each reply drains the oldest; replyapprove iauth_…to target a specific one. If the admin's reply arrives after Stripe's 2-second window, agentdesk saves an auto-approve rule (same behaviour asagentdesk requests approveon an expired auth) so the next identical request is handled automatically.
| Flag | Default | Description |
|---|---|---|
--port |
config.daemon_port or 4242 |
HTTP port to listen on. |
--secret |
config.webhook_secret |
Webhook signing secret. Required unless --insecure-skip-verify. |
--path |
/webhook |
HTTP path for the Stripe webhook. |
--insecure-skip-verify |
Skip webhook signature verification (local dev only). | |
--no-nostr |
Disable the Nostr NIP-04 listener entirely. | |
--nostr-relay <wss://…> |
Override the default relay list. Repeatable. |
Two ways to feed events to the daemon:
(a) Local dev — Stripe CLI listen (no public URL required):
# in terminal A — stream live events from Stripe to your daemon
stripe listen --forward-to localhost:4242/webhook
# in terminal B — run the daemon (take the secret printed by stripe listen)
agentdesk daemon --port 4242 --secret whsec_xxxxxxx
# in terminal C — generate a test authorization
stripe trigger issuing_authorization.request(b) Production — register a real endpoint (requires a public HTTPS URL, e.g. ngrok / cloudflared / your production host):
# register: creates /v1/webhook_endpoints on Stripe, saves the signing secret
agentdesk daemon register --url https://example.com/webhook
# run the daemon (secret is read from config.json)
agentdesk daemonCall Stripe's POST /v1/webhook_endpoints to register your public URL for the issuing_authorization.* events, and save the signing secret to ~/.agentdesk/config.json. Must be HTTPS.
| Flag | Description |
|---|---|
--url <https-url> |
Required. Publicly reachable webhook URL. |
--json |
Emit the endpoint + secret as JSON. |
agentdesk daemon register --url https://agentdesk.example.com/webhook# one-time setup (API key + default cardholder identity)
agentdesk setup \
--api-key sk_test_xxx \
--first-name Ada --last-name Lovelace --dob 1815-12-10 \
--address-line1 "8 rue de Londres" --city Paris \
--postal-code 75009 --country FR
# onboard an agent with a €250/month budget and allowed merchants
agentdesk agents add \
--name research-agent \
--allowance 250 \
--interval monthly \
--merchants openai.com,anthropic.com \
--json > agent.json
# extract card details to feed the agent
jq -r '.number' agent.json
jq -r '.cvc' agent.json
# later: review spend
agentdesk agents --json | jq '.[] | {name, spent_cents, remaining_cents}'
# review pending authorization requests and approve one
agentdesk requests --limit 50 --json | jq '.[] | select(.status=="pending") | .id'
agentdesk requests approve iauth_1Abc123- Card PAN/CVC are retrieved from Stripe only once at creation time (via
expand=number,cvcon retrieve) and written locally with0600permissions. Treat~/.agentdesk/as sensitive. - The Stripe API key is stored in plain JSON under
~/.agentdesk/config.json(0600). Prefer a restricted-scope key (Issuing read/write) over your root secret key. - Non-interactive mode is scriptable and CI-friendly; do not commit
~/.agentdesk/to source control.
Built at a hackathon. Rough edges:
- Issuing currency is hardcoded to EUR. To support other currencies, parametrise
stripeapi.CreateVirtualCard. - The daemon only auto-approves on rule matches; it doesn't yet auto-decline. For unmatched requests it responds 200 and lets Stripe's
spending_controlson the card decide. - Rules match on exact
{card, merchant, amount, date}— no fuzzy matching or TTL. Consider clearingrules.jsonperiodically. - Only a single spending-limit tier per card (amount + interval). Add more via
stripe.IssuingCardSpendingControlsParams.SpendingLimitsif you need e.g. daily + monthly caps. - Billing details are shared across all agents. If you need per-agent KYC, extend the
addcommand to accept overrides.
Contributions welcome.