Inspiration
AI agents are starting to buy things on their own: API credits, compute, hosting. The usual way to let them do that is to hand them a payment API key, which gives them no limits at all. An agent with a key can overspend in one payment or over a day, pay someone it shouldn't, pay twice when a request is retried, make a large payment nobody looked at, or report a payment as done while the rail hasn't settled it.
We wanted a small, auditable layer between the agent and the payment rail. The agent (and the model behind it) can ask to pay, but it never holds the keys and never gets the final say.
What it does
- An LLM plans the payment. The agent takes a plain-English task such as "buy 1M tokens of GPT inference" and asks an LLM (OpenAI
gpt-4o-miniby default) to turn it into a payment intent: payee, amount, currency and purpose. The model's answer is schema-checked. If the call fails, times out or returns something invalid, the agent falls back to a deterministic rule-based planner and records why. - The LLM only proposes. The intent goes to a local HTTP API (
POST /intents). The model never sees PayPal credentials and never calls PayPal. - A policy engine decides. Each intent is checked against the agent's policy: per-transaction max, daily max, payee allowlist, currency and an approval threshold. The result is
approved,rejected(for exampleper_tx_limit_exceeded,payee_not_allowed,daily_limit_exceeded) orneeds_approval. - A human approves large payments. Intents above the threshold wait until a person approves or rejects them over HTTP or in the web demo. Limits and the allowlist are checked again at approval time.
- Everything is written to a ledger. Intents, decisions, payments and receipts go to a JSON ledger in a fixed order (intent → decision → payment → receipt), written atomically. Re-sending the same intent returns the stored result instead of paying twice.
- PayPal Payouts moves the money. Only approved intents reach the rail. With
RAIL=paypalthe API sends a PayPal Payouts v1 sandbox payout to an email payee, using the intent id assender_batch_id. - Pending is not paid. A payout batch can stay
PENDINGfor a while. The receipt stayspendinguntil PayPal reports a final status; a background poller andPOST /intents/:id/refreshsettle it. A payout that turns outfailedstops counting toward the daily total. - CLI and web demo. The agent CLI prints each step (task → quote → policy → approval → payment → receipt), including which planner made the quote, and exits 0 (paid), 2 (rejected), 3 (timed out) or 1 (failed). A React web demo shows the same pipeline with a live ledger timeline.
Live results on Nov 1, 2026, with the LLM planner:
- PayPal Payouts sandbox: batch
R75P598ZPRDQ4, item8MU43SKES75CQ, SUCCESS. An earlier run, batchP623XXQ4YNZXN, also SUCCESS. - An over-limit request was rejected by the policy with
per_tx_limit_exceededbefore any PayPal call. - The same core also runs on NEAR testnet: tx
6GHFBVBy8m5RDgj4pDEa1T6p8k37QFj4L9gq48UhFKx7(FINAL), and earlierGtVhMtch5irXQuVXhUzfEBaqQb6MJzCu5Jh1XkMPyCG3(FINAL).
How we built it
- AI planner.
--planner llmuses OpenAI Chat Completions whenOPENAI_API_KEYis set (model fromOPENAI_MODEL, defaultgpt-4o-mini), else OpenRouter whenOPENROUTER_API_KEYis set, else the rule-based planner with no request sent. It makes at most one LLM call per run, with temperature 0, JSON response format and a 15 s timeout, and never retries on the other provider. The prompt gives the model the known payees and reference prices and tells it that it only proposes. Its output is validated (payee format, positive amount with at most 2 decimals, matching currency, non-empty purpose). Every quote carries a trace with the planner, provider, model and, on fallback, the reason. API keys are masked from any error text. - Language and runtime. TypeScript run directly by Node 24 (type stripping, no build step). Core, API and agent have no third-party runtime dependencies; the LLM client is plain
fetch. packages/core. Types, a pure policy engine, the JSON ledger and the orchestrator (run, approve, reject, refresh, reconcile, manual resolve). Invalid policies fail closed.packages/adapters.mock,paypal(Payouts v1 with OAuth client credentials against the sandbox, plus status refresh) andnear(native transfer through a small JSON-RPC client). A PayPal Orders v2 adapter also exists on the adapter side.apps/api. Anode:httpserver with anIdempotency-Keyheader, a{error, message}error contract, request timeouts, CORS only for localhost, and a bind to 127.0.0.1 by default. Credentials come only from the environment. If one is missing, startup stops and prints its name, never its value.apps/agent. The task-agent CLI with aPlannerinterface (LLM or rule-based) and NDJSON output (--json).apps/web. React 18 + Vite demo with an in-memory mode and an API mode.- Tests.
node --testin each workspace (the LLM planner tests use a mocked fetch) and Vitest for the web app. Live PayPal and NEAR integration tests skip themselves unless their env vars are set.npm run secret-scanchecks tracked files for key-shaped strings.
Challenges we ran into
- Letting a model near money. We had to decide what the LLM is allowed to do. The answer: it writes a proposal, and nothing else. Its output goes through a schema check, then the same policy engine and approval step as any other request, and any failure falls back to deterministic rules instead of guessing.
- Payouts are asynchronous. A payout batch comes back
PENDING, so "submitted" and "settled" had to be separate states. We added receipt refresh, a background poller, reconciliation, and a manual resolve path that requires an audit note. - Never paying twice. Retries, duplicate
sender_batch_idresponses and timeouts all had to map to a stored result or a "needs check" state instead of a second payout. - Approval races. Two approvers (or approve vs. reject) can hit the same intent. The second one gets
409, and the web UI reloads the ledger. - Keeping secrets out. Logs, startup errors and planner traces redact PayPal, NEAR, OpenAI and OpenRouter values and print names only.
Accomplishments that we're proud of
- A PayPal sandbox payout (batch
R75P598ZPRDQ4, SUCCESS) whose amount and payee came from an LLM, and which was paid only after the policy engine allowed it. - An over-limit proposal stopped by policy (
per_tx_limit_exceeded) with no rail call at all. - The same planner, policy, ledger and approval core drives three rails (mock, PayPal sandbox, NEAR testnet) without changing the agent.
- No third-party runtime dependencies in core, API and agent.
What we learned
- An LLM is useful for turning a vague task into a structured payment request, but it should never be the thing that decides whether money moves.
- An agent payment system is mostly about state: intent, decision, submission, receipt, and what to do when the rail's answer is late, unknown or different.
- Policy checks have to run twice: when the agent asks and again when a human approves.
- "Pending" has to be its own state. Treating it as success leads to double payments and spend nobody can account for.
What's next for agent-pay
- Let the LLM explain its proposal to the human approver, and let the approver edit it before approving.
- Persist policies, support several agents, and replace the single-writer JSON ledger with a database.
- More PayPal surfaces (the Orders v2 adapter already exists) and refunds where the rail allows them.
Built With
- gpt-4o-mini
- json-rpc
- near-protocol
- node.js
- openai
- openrouter
- paypal
- paypal-payouts-api
- paypal-sandbox
- playwright
- react
- rest-api
- typescript
- vite
- vitest
Log in or sign up for Devpost to join the conversation.