Inspiration

Many guides in Mauritius work on their own: a hiking guide on Le Morne, a kayak guide in the lagoon, a forest walker in Black River Gorges. Many of them aren't on booking platforms. Booking one means a WhatsApp chat, and the deposit is a bank transfer to a stranger or cash on the day.

AI travel assistants can already suggest trips like these. We wanted one that can book the trip and still leave the traveller in charge of the money. The agent arranges, the traveller approves, and the money goes from the traveller straight to the guide. We never hold it.

What it does

You type: "Find me a hiking guide for Le Morne on Thursday and hold the spot."

  1. Ambree finds a guide and asks for a price. The agent works out the place, the activity and the day (and asks how many people if it needs to), searches the guides, picks one and sends a booking request. The guide gets a private link and answers from their phone: a total, a deposit and a note. To answer, the guide needs no Ambree app or login.
  2. Ambree proposes a mandate. The mandate holds the deposit amount, the payee (the guide's PayPal merchant id), a hash of the guide's quote, a 15-minute expiry and a one-time nonce. You approve it with a 6-digit code sent to you. The agent never sees that code, and none of its tools can approve a mandate.
  3. Only then can the agent create a PayPal order. It is an Orders v2 order whose payee is the guide's own account, so the money goes from the traveller to the guide.
  4. You pay in PayPal checkout. Ambree captures the payment. PayPal's webhooks are checked with PayPal's signature verification and written to a hash-chained ledger, which the page verifies ("Chain OK").
  5. If plans change, the deposit goes back the same way. When a cyclone warning covers the day, Ambree explains the risk in one sentence and asks the guide to move the trip. If the guide can't, or offers a refund, Ambree triggers the refund with the guide's own PayPal app credentials, and the deposit goes from the guide's account back to you. It refunds only for a real cyclone warning from the weather feed, the guide's decline or the guide's offer. In this build the warning comes from a demo control, and a simulated warning alone never allows a refund; the real cyclone feed is not wired yet.

One page shows it all: the chat, the "Guide's phone" (the guide's private link), "Your inbox" (the step-up code) and the ledger.

The money rules are in code, not in the prompt.

  • An order is created only by using up an approved, unexpired, unused mandate whose amount, payee and quote hash still match.
  • Before capturing, the app reads the order back from PayPal. It must have exactly one purchase unit, the mandate's payee, amount and currency, and the booking's reference. After capture, the captured amount must match too. Anything else puts the booking in "needs review", and it is not marked paid.
  • "Paid" and "refunded" mean PayPal said COMPLETED. Otherwise the page says "Payment pending", "Payment failed" or "Payment needs review".
  • Each refund is saved before it is sent, retried unchanged, and checked against PayPal's reply. A reply with a different amount stops further refunds on that booking until someone reviews it.

We never hold the money. The guide's account is the payee, and refunds come out of the guide's capture, sent with a second PayPal app that the guide's own account owns. Accounts are compared by PayPal merchant id only, because one PayPal account can have several email addresses. The app refuses to start if a guide's merchant id is the app owner's. It refuses to create an order to the app owner, and refuses to capture an order whose payee is the app owner or has no merchant id.

All five guides are invented and named "(demo)". None is a real person or business. Everything runs on the PayPal sandbox only.

How we built it

TypeScript on Node 22.18 or later, run directly, with no build step and no web framework (node:http). Bookings and the ledger live in Node's built-in node:sqlite. Inputs are checked with zod. The page is plain HTML and JavaScript.

PayPal Developer Platform (sandbox)

PayPal API How we use it
OAuth 2.0: POST /v1/oauth2/token A client-credentials token for each of two sandbox REST apps, cached until 60 seconds before it expires: ours makes and captures the orders, and the guide's own app, owned by the guide's sandbox business account, makes and reads the refunds
Orders v2, create: POST /v2/checkout/orders One purchase unit per deposit: intent: CAPTURE, payee.merchant_id = the guide, the deposit amount, the booking id in reference_id and custom_id, and the return and cancel URLs. The PayPal-Request-Id is the mandate's one-time nonce, so a retry can't create a second order
PayPal checkout The traveller pays on PayPal's hosted sandbox checkout through the order's approve link, then comes back to our return URL
Orders v2, show: GET /v2/checkout/orders/{id} Reads the order back before capture and checks it against the mandate
Orders v2, capture: POST /v2/checkout/orders/{id}/capture One idempotent capture, from whichever comes first: the return URL, the CHECKOUT.ORDER.APPROVED webhook, or the agent's capture_order tool
Payments v2, refund: POST /v2/payments/captures/{id}/refund Full or partial refunds from the guide's capture, sent with the guide's own app (PayPal refuses ours with 403), each with its own saved PayPal-Request-Id
Payments v2, show refund: GET /v2/payments/refunds/{id} Follows a pending refund to its final status, also with the guide's app
Webhooks v1, verify: POST /v1/notifications/verify-webhook-signature Every webhook is verified with PayPal before it is logged, and it is logged once. Two endpoints share one handler: our app sends CHECKOUT.ORDER.APPROVED, the PAYMENT.CAPTURE.* and the PAYMENT.REFUND.* events to /webhooks/paypal, and the guide's app is subscribed to the refund events at /webhooks/paypal/guide. Each accepts only its own app's webhook id, and one event id is processed once, whichever endpoint it reaches. On the sandbox, PayPal sent even the refund's event to our app's webhook, not the guide app's
Identity: POST /v2/checkout/orders with no payee At startup, under each app's token, creates an order with no payee and reads the payee PayPal fills in: the app owner's merchant id. Nobody approves it, and it lapses. The app won't start unless our app's owner is the configured operator id and no guide's, and the guide's app's owner is the guide. /healthz reports operator_identity and guide_identity

The PayPal base URL is fixed to api-m.sandbox.paypal.com, and the app refuses any other.

We also wrote:

  • an in-process fake PayPal that speaks the same JSON as the sandbox for these calls and even sends its webhooks to the app over HTTP, so the offline demo and the tests run the real client code. Like the real sandbox, it refuses a refund from any app but the one the payee owns;
  • scripts/spike.ts, which runs one real sandbox order to another sandbox account (token, create, your approval, capture, refunds) and prints a table of HTTP statuses and ids, never a token. On 3 Nov it showed that our app can create and capture an order paid to the guide but can't refund it, and that the guide's own app can. A repeated PayPal-Request-Id replays the first refund, and refund replies carry our custom_id, a link to the capture and a running refunded total.

AI in the product

  • The agent is a tool loop (up to 8 model steps per message) over any OpenAI-compatible /chat/completions endpoint with tool calling. On the hosted site the model is nvidia/nemotron-3-ultra-550b-a55b:free, through OpenRouter. It has ten tools: search_guides, request_quote, get_booking, propose_mandate, create_order, get_order, capture_order, check_weather, propose_reschedule and refund_payment. The model works out what the traveller asked for, chooses the guide and the tool calls, and writes the replies. Events (the guide priced, you approved, you paid, a cyclone warning) reach it as notes, so it acts on them without being asked.
  • The scripted model is not AI. It is a rule-based stand-in that makes the same tool calls, so the demo and the tests run without a model key. The page header says which model is running.
  • MCP. The same ten tools are served over the Model Context Protocol (Streamable HTTP, with the official TypeScript SDK) at /mcp, behind a passcode. An MCP client can search guides, check the weather and send a booking request. Pricing, approval and payment happen only on the page: MCP calls run in their own scope, so they can't see a traveller's bookings, and only a traveller can approve a mandate.

AI tools we used to build it

  • Claude Code (Anthropic), with Claude Opus 5.5 and Claude Haiku 5.5 for smaller steps, wrote the design document, the code, the tests and the docs from our brief.
  • OpenAI Codex did three read-only security reviews of the code and never edited it. Later review rounds on the refund and webhook code were done by separate Claude sessions that also never edited it. The reviews found, among other things: a booking id could stand in for the traveller's authority; nothing stopped the app owner's own account from being set as a payee; capture didn't check the order against the mandate; and a rejected order could be retried after its mandate expired. Each fix came with a test that failed before it and passes after.

Tests. 1706 offline unit and integration tests (vitest, 44 files) cover every PayPal call and error path against the fake, the mandates, ledger tampering, webhooks on both endpoints, refunds routed to the guide's own app, and sessions. 7 Playwright browser tests run the payment path and the cyclone refund path, offline and in the hosted sandbox mode, type the step-up code at human speed, and reload the guide's page after it answers. No test calls PayPal or a model.

Built during the submission period. We started the repository on 31 Oct 2026, inside the submission period, and wrote all of its code for this hackathon. It reuses no earlier code. The ideas behind it (a step-up mandate, a hash-chained ledger, a guide link with no login) come from our own earlier design notes for Ambree.

Hosting. https://paypal.ambree.ai has run since 3 Nov on the PayPal sandbox only (the app refuses live PayPal) and stays up through 15 Dec 2026: one pod in its own namespace on a small Kubernetes (k3s) server, behind Traefik with a Let's Encrypt certificate. A default-deny network policy lets it reach only public HTTPS. The PayPal and model credentials are in a Kubernetes Secret created by hand, never in git or the image. Bookings and the ledger live in SQLite on a 64Mi volume.

Challenges we ran into

  • Paying someone who isn't us, and refunding it. Orders v2's payee field sends the money to the guide, and on the real sandbox our app created and captured that order. Refunding it answered 403 PERMISSION_DENIED, and acting for the guide (PayPal-Auth-Assertion, Partner Referrals) needs partner features we don't have. So the guide's own sandbox account has a second REST app, and Ambree triggers the refund with the guide's own PayPal app credentials. In production we would use PayPal's partner onboarding instead, with the guide's consent to refunds, and hold no guide credentials.
  • Proving we're never the payee. An email can be an alias of another account, so we compare merchant ids only. PayPal's userinfo call has no merchant id for an app's token. Instead, at startup the app creates an order with no payee under each app's token, and PayPal fills in that app's owner as the payee; nobody approves the order, and it lapses. The app won't start unless PayPal names us as our app's owner and the guide as the guide app's owner, and our id is no guide's. The explicit opt-out shows on /healthz as "config-only (unconfirmed)".
  • Keeping the model away from the money. The prompt states the rules, but nothing depends on the model following them. No tool approves a mandate, the step-up code never appears in a tool result, and the booking service re-checks everything on every call that moves money.
  • Timeouts versus refusals. When an order create times out, we can't know whether PayPal created the order. A clear refusal spends the mandate. An unclear failure is retried with the same PayPal-Request-Id, but only while the mandate is still valid.
  • Webhooks come late, twice, or not at all. The capture reply decides "paid". Webhooks are verified, logged once per event id, and accepted again if processing failed the first time. We didn't know which app PayPal would notify about a refund made with the guide's app, so both have a webhook, and an event that reaches both endpoints is processed once; on the sandbox it was ours. The sandbox can also take many minutes to deliver an event, so nothing waits for one.
  • Two things at once on one booking. The guide accepts a new date while the agent refunds; a webhook lands mid-refund; an old guide tab answers a newer question. Each booking's steps now run one at a time on its own queue, and a guide's answer names the exact proposal it answers, so a stale tab is refused.
  • A refund whose reply never came back. If PayPal's reply is lost, the refund may or may not have happened. The app records it as unknown, never as failed. Whatever arrives next (PayPal's webhook, a read-back, or the agent's retry with the same PayPal-Request-Id) settles it the same way, and a refund the booking can't account for holds it for a person to review. A test runs every arrival order.

Accomplishments that we're proud of

  • The whole flow runs end to end with npm start and no keys: request, quote, mandate, checkout, capture, ledger, cyclone warning, reschedule, refund.
  • The mandate is enforced in code: a 6-digit code of which the mandate stores only a hash, a lock after 5 wrong codes, a 15-minute expiry, a 128-bit single-use nonce that is also the order's idempotency key, and a pinned quote hash.
  • The ledger shows tampering. Each row's hash covers the row before it, so an edited, inserted or reordered row breaks the chain. A deleted tail is caught against the head kept outside the database (/api/ledger/head).
  • The page never says "Paid" unless PayPal said COMPLETED.
  • Every review round ended with its fixes tested: each fix landed with a test that failed first.

What we learned

  • PayPal's payee field is what keeps us out of the money flow. Proving which account is ours took more work than sending the payment.
  • Without partner permissions, PayPal's refund permission follows the money: only the account a capture paid may refund it.
  • PayPal-Request-Id makes retries safe. Making it the mandate's nonce means one approval can make at most one order.
  • The capture reply is the record of a payment, and a webhook confirms it later. We had to design for webhooks that arrive late or more than once.
  • The AI is good at the conversation: understanding the request, picking a guide, explaining a cyclone warning. Decisions about money stay in code it can't change.
  • A small guide needs a link that works on a phone, not another app.

What's next for Ambree: book the guide

  • PayPal partner onboarding, with the guide's consent to refunds and PayPal-Auth-Assertion sent from Ambree's own app, so a guide joins without making a REST app and Ambree holds no guide credentials.
  • Plug in PayPal's Agent Toolkit (@paypal/agent-toolkit) as a second payments adapter behind the same mandate check, once we confirm its create_order can set the payee. We won't hand its tools to the model directly, because the model could then create orders without a mandate.
  • PayPal JS SDK buttons instead of the redirect to checkout.
  • The real cyclone feed (GDACS) instead of the demo warning.
  • Each guide's own PayPal account. Today one sandbox business account, with its own REST app for refunds, plays all five invented guides.
  • The step-up code by email or SMS, instead of the demo inbox.
  • Before any real guide: settle whether booking on someone's behalf counts as tour-operator work in Mauritius, even though we never hold the money.

Built With

  • ai-agents
  • claude-code
  • k3s
  • kubernetes
  • mcp-typescript-sdk
  • model-context-protocol
  • node.js
  • nvidia-nemotron
  • openai-codex
  • openrouter
  • paypal
  • paypal-orders-v2
  • paypal-payments-v2
  • paypal-rest-api
  • paypal-sandbox
  • paypal-webhooks
  • playwright
  • sqlite
  • tool-calling
  • traefik
  • typescript
  • vitest
  • zod
Share this project:

Updates

Submission history