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-mini by 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 example per_tx_limit_exceeded, payee_not_allowed, daily_limit_exceeded) or needs_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=paypal the API sends a PayPal Payouts v1 sandbox payout to an email payee, using the intent id as sender_batch_id.
  • Pending is not paid. A payout batch can stay PENDING for a while. The receipt stays pending until PayPal reports a final status; a background poller and POST /intents/:id/refresh settle it. A payout that turns out failed stops 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, item 8MU43SKES75CQ, SUCCESS. An earlier run, batch P623XXQ4YNZXN, also SUCCESS.
  • An over-limit request was rejected by the policy with per_tx_limit_exceeded before any PayPal call.
  • The same core also runs on NEAR testnet: tx 6GHFBVBy8m5RDgj4pDEa1T6p8k37QFj4L9gq48UhFKx7 (FINAL), and earlier GtVhMtch5irXQuVXhUzfEBaqQb6MJzCu5Jh1XkMPyCG3 (FINAL).

How we built it

  • AI planner. --planner llm uses OpenAI Chat Completions when OPENAI_API_KEY is set (model from OPENAI_MODEL, default gpt-4o-mini), else OpenRouter when OPENROUTER_API_KEY is 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) and near (native transfer through a small JSON-RPC client). A PayPal Orders v2 adapter also exists on the adapter side.
  • apps/api. A node:http server with an Idempotency-Key header, 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 a Planner interface (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 --test in 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-scan checks 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_id responses 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

Share this project:

Updates

Submission history