@ambasada/skills 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,23 @@
1
- # ambasada-payments skill
1
+ # @ambasada/skills
2
2
 
3
- `skill/` is the skill: `SKILL.md` (frontmatter `name`, `description`) plus `reference/` and `recipes/`.
4
- Install it in a project for Claude Code (`.claude/skills/ambasada-payments/`), Cursor or Codex
5
- (point the agent at `SKILL.md`). `ambasada init` vendors it into every new company.
3
+ Skills for AI coding tools (Claude Code, Cursor, Codex) building products for the Hack A Ton Village.
6
4
 
7
- After editing, run `pnpm --filter @ambasada/skills sync` to update the template copy;
8
- `pnpm test` fails when they differ or a link is broken.
5
+ | Skill | Use it to |
6
+ |---|---|
7
+ | [`ambasada-product`](skills/ambasada-product/SKILL.md) | build a product that sells in the Village: the Next.js-on-Cloudflare layout, allowed features, data and secrets, `ambasada dev` / `deploy` / `link`, design, a sellable checklist, prompts for ChatGPT and Lovable |
8
+ | [`ambasada-payments`](skills/ambasada-payments/SKILL.md) | take and spend TON: Checkout, pay.js, webhooks, receipts, `buy` / `need`, LLM, images, tools, files; recipes for Next.js, static pages, Lovable/Bolt and self-hosted stacks |
9
+
10
+ Each skill is a folder with `SKILL.md` (frontmatter `name`, `description`) plus `reference/`, `recipes/` or
11
+ `checklists/`.
12
+
13
+ Install:
14
+
15
+ - New project: `npm create ambasada@latest my-shop` vendors both into `.claude/skills/`.
16
+ - Existing project: `npx skills add centyso-dev/ambasada-skills` (public mirror), or copy `skills/*` into
17
+ `.claude/skills/`. Cursor and Codex: point the agent at the `SKILL.md` files.
18
+
19
+ After editing, run `pnpm --filter @ambasada/skills sync`: it copies every skill into each
20
+ `templates/*/.claude/skills/` (and the legacy `template/.claude/skills/` while it exists).
21
+ `pnpm test` (`node scripts/sync.mjs --check`) fails on bad frontmatter, a broken relative link, or a stale copy.
9
22
 
10
23
  Built by [CENTYSO](https://centyso.com) for [Ambasada](https://ambasada.id). MIT.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ambasada/skills",
3
- "version": "0.1.0",
4
- "description": "The ambasada-payments skill (SKILL.md + reference docs + recipes) for Claude Code, Cursor and Codex.",
3
+ "version": "0.3.0",
4
+ "description": "Skills for Claude Code, Cursor and Codex: ambasada-product (build a product that sells in the Hack A Ton Village) and ambasada-payments (TON Checkout, receipts, webhooks).",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "skills",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ambasada-payments
3
- description: Add TON payments to any app for the Hack A Ton village. Use when the user wants to accept TON from people (Checkout, pay.js button, webhooks), sell an HTTP endpoint that agents pay per call (verify the x-village-receipt), buy from other companies (village.buy / village.need), use the village LLM, images, tools or files, list offerings, or test all of it offline with `ambasada dev`. Covers Next.js, Express, FastAPI or any HTTP stack, Astro/static pages with pay.js, and Lovable/Bolt.
3
+ description: Add TON payments to any app for the Hack A Ton village. Use when the user wants to accept TON from people (Checkout, pay.js button, webhooks), sell an HTTP endpoint that agents pay per call (verify the x-village-receipt), buy from other companies (village.buy / village.need), use the village LLM, images, tools or files, list offerings, or test all of it offline with `ambasada dev`. Covers Next.js on Cloudflare (the default template), static pages with pay.js, Lovable/Bolt, and self-hosted stacks (Express, FastAPI, Workers, Astro).
4
4
  ---
5
5
 
6
6
  # Village payments
@@ -19,14 +19,22 @@ Amounts are TON as decimal numbers. Errors are always `{ "error": { "code", "mes
19
19
 
20
20
  ## Decide what the user needs
21
21
 
22
- 1. *"Let people pay on my site"* → [reference/checkout.md](reference/checkout.md). Static page without a backend → [recipes/astro-static-payjs.md](recipes/astro-static-payjs.md).
22
+ 1. *"Let people pay on my site"* → [reference/checkout.md](reference/checkout.md). Static page without a backend → [recipes/static-payjs.md](recipes/static-payjs.md).
23
23
  2. *"Let agents buy my service"* → [reference/receipts.md](reference/receipts.md), then list it as an offering ([reference/primitives.md](reference/primitives.md#listings)).
24
24
  3. *"My product needs something another team sells"* → [reference/need-vs-buy.md](reference/need-vs-buy.md).
25
25
  4. *"Use an LLM / generate images / search the web"* → [reference/primitives.md](reference/primitives.md#utilities) (paid in TON from the company wallet).
26
26
  5. Always set up **test mode** first → [reference/test-mode.md](reference/test-mode.md) (`ambasada dev`, no login, fake money).
27
27
 
28
- Stack recipes: [Next.js](recipes/nextjs.md) · [Express](recipes/express.md) · [FastAPI / plain HTTP](recipes/fastapi.md) ·
29
- [Astro / static + pay.js](recipes/astro-static-payjs.md) · [Lovable / Bolt / v0](recipes/lovable-bolt.md) · [Cloudflare Workers](recipes/workers.md).
28
+ Stack recipes:
29
+
30
+ - [Next.js on Cloudflare](recipes/nextjs.md): the default (`npm create ambasada@latest`), hosted by `ambasada deploy`.
31
+ - [Static page + pay.js](recipes/static-payjs.md): no backend, a payment link and a button.
32
+ - [Lovable / Bolt / v0](recipes/lovable-bolt.md): prompts to paste into the builder.
33
+ - [Other stacks](recipes/other-stacks.md): Express, FastAPI, a bare Worker, Astro; power users who host
34
+ it themselves, then `ambasada link`.
35
+
36
+ Building the whole product (layout, allowed Next.js features, data, deploy, design)? Use the
37
+ **ambasada-product** skill as well.
30
38
 
31
39
  ## Rules that prevent real bugs
32
40
 
@@ -39,12 +47,17 @@ Stack recipes: [Next.js](recipes/nextjs.md) · [Express](recipes/express.md) ·
39
47
  schema) makes the Gateway refund the buyer. Throwing is the refund path.
40
48
  - **Idempotency.** Send `Idempotency-Key` on purchases and transfers; retries with the same key never charge twice.
41
49
  - **One receipt, one call.** Dedupe on `call_id` (the SDK does it per process; persist it if you run several instances).
42
- - TypeScript: use `@ambasada/sdk` (`npm i @ambasada/sdk`). Other languages: plain HTTP, shown in each recipe.
50
+ - TypeScript: use `@ambasada/sdk` (`npm i @ambasada/sdk`). Other languages: plain HTTP ([recipes/other-stacks.md](recipes/other-stacks.md#fastapi-or-any-language-over-plain-http)).
43
51
 
44
52
  ## The SDK in 20 lines
45
53
 
54
+ The examples call the client `village`. `import { village } from "@ambasada/sdk"` reads `process.env`
55
+ (`AMBASADA_API_KEY`, `AMBASADA_API_URL`, `AMBASADA_COMPANY`, `AMBASADA_WEBHOOK_SECRET`); on Cloudflare make
56
+ one with `createAmbasada({ env })`. In the Next template it is `ambasada()` from `lib/ambasada.ts`.
57
+ The SDK still reads the old `VILLAGE_*` names as a fallback.
58
+
46
59
  ```ts
47
- import { village } from "@ambasada/sdk"; // reads AMBASADA_API_KEY, AMBASADA_API_URL
60
+ import { village } from "@ambasada/sdk";
48
61
 
49
62
  // sell to agents: a fetch handler that verifies the receipt and answers JSON (402 if unpaid)
50
63
  export const POST = village.sell("translate", async ({ text, to }, call) => {
@@ -1,9 +1,12 @@
1
1
  # Lovable, Bolt, v0 and other no-code builders
2
2
 
3
- These tools build from a prompt, so give them the docs instead of code.
3
+ These tools build from a prompt, so give them the docs instead of code. Two options: keep hosting there
4
+ (below), or export the code into the `spa` template and use `ambasada deploy` (the ambasada-product skill,
5
+ `reference/build-elsewhere.md`). Either way, finish with `ambasada link https://your.app` or `ambasada deploy`.
4
6
 
5
7
  **1. Static "Pay with TON" button (works everywhere, no backend):** create a payment link with
6
- `ambasada links create …` (or in the village app → Payments), then paste this into the builder's chat:
8
+ `ambasada links create --amount 3 --description "…" --success-url https://your.app/thanks`, then paste this
9
+ into the builder's chat:
7
10
 
8
11
  > Add a "Pay 3 TON" button. Include `<script src="https://hackaton.ambasada.id/pay.js" defer></script>` in the
9
12
  > page head and render `<button data-village-pay="pl_XXXX">Pay 3 TON</button>` (add `data-mode="popup"` to
@@ -24,7 +27,7 @@ Supabase Edge Functions or similar; paste this:
24
27
 
25
28
  Then register the webhook URL: `ambasada webhook set https://…/functions/v1/village-webhook`.
26
29
 
27
- **3. Selling to agents** needs an endpoint that verifies `x-village-receipt` (reference/receipts.md). In an
30
+ **3. Selling to agents** needs an endpoint that verifies `x-village-receipt` ([reference/receipts.md](../reference/receipts.md)). In an
28
31
  edge function with npm support: `import { village } from "npm:@ambasada/sdk"` and
29
32
  `Deno.serve(village.sell("translate", async (input) => ({ … })))`.
30
33
 
@@ -1,54 +1,150 @@
1
- # Next.js (App Router)
1
+ # Next.js on Cloudflare (the default template)
2
2
 
3
- `npm i @ambasada/sdk` · `.env.local`: `AMBASADA_API_KEY=…` (`AMBASADA_API_URL=http://127.0.0.1:8788` while on `ambasada dev`), `AMBASADA_WEBHOOK_SECRET=whsec_…`
3
+ `npm create ambasada@latest my-shop` gives you all of this already: Next.js 16.4 App Router, built into one
4
+ Cloudflare Worker by `@opennextjs/cloudflare` 1.20 with `nodejs_compat`. Local secrets in `.env`
5
+ (`AMBASADA_API_KEY`, `AMBASADA_API_URL`, `AMBASADA_COMPANY`, `AMBASADA_WEBHOOK_SECRET`); `.dev.vars` works too. What runs on
6
+ Workers and what doesn't: the ambasada-product skill, `reference/stack.md`.
7
+
8
+ ## The client: `lib/ambasada.ts`
9
+
10
+ Server code only. One client per Worker instance: it caches the receipt keys and remembers used receipts.
11
+
12
+ ```ts
13
+ // lib/ambasada.ts (the template ships it; this is the core)
14
+ import { createAmbasada, type Village } from "@ambasada/sdk";
15
+ import { getCloudflareContext } from "@opennextjs/cloudflare";
16
+
17
+ function cloudflareEnv(): Record<string, unknown> | undefined {
18
+ try {
19
+ return getCloudflareContext().env as unknown as Record<string, unknown>;
20
+ } catch {
21
+ return undefined; // `next dev`: the SDK reads process.env
22
+ }
23
+ }
24
+
25
+ let client: Village | undefined;
26
+
27
+ export function ambasada(): Village {
28
+ return (client ??= createAmbasada({ env: cloudflareEnv() }));
29
+ }
30
+ ```
31
+
32
+ It also exports `env(name)` (a variable from the Worker env, else `process.env`) and `villageUrl()`
33
+ (the Village, or the mock under `ambasada dev`).
34
+
35
+ ## An offering agents pay for
4
36
 
5
37
  ```ts
6
- // app/api/translate/route.ts: an offering agents pay for (list it with endpoint https://your.app/api/translate)
7
- import { village } from "@ambasada/sdk";
8
- export const runtime = "nodejs";
38
+ // app/api/translate/route.ts (offering endpoint "/api/translate")
39
+ import { ambasada } from "../../../lib/ambasada.ts";
9
40
 
10
- export const POST = village.sell<{ text: string; to: string }>("translate", async ({ text, to }) => ({
11
- text: await village.llm(`Translate to ${to}. Reply with the translation only.\n\n${text}`),
12
- }));
41
+ export const POST = (req: Request) =>
42
+ ambasada().sell<{ text: string; to: string }>("translate", async ({ text, to }) => ({
43
+ text: await ambasada().llm(`Translate to ${to}. Reply with the translation only.\n\n${text}`),
44
+ }))(req);
13
45
  ```
14
46
 
47
+ `sell` answers `402` without a valid receipt, runs your handler, returns its JSON. Throw a `VillageError`
48
+ (or let it fail) and the buyer is refunded.
49
+
50
+ ## People pay: Checkout
51
+
15
52
  ```ts
16
- // app/api/checkout/route.ts: people pay
17
- import { village } from "@ambasada/sdk";
53
+ // app/api/checkout/route.ts (the landing page: <form action="/api/checkout" method="post">)
54
+ import { ambasada } from "../../../lib/ambasada.ts";
18
55
 
19
56
  export async function POST(req: Request) {
20
- const { plan } = await req.json();
21
- const session = await village.checkout({
22
- amount: plan === "pro" ? 10 : 3,
23
- description: plan === "pro" ? "Pro pack" : "Starter pack",
24
- success_url: `${new URL(req.url).origin}/thanks?session={CHECKOUT_SESSION_ID}`,
25
- client_reference_id: crypto.randomUUID(),
26
- });
27
- return Response.json({ url: session.url }); // client: location.href = url
57
+ const origin = new URL(req.url).origin;
58
+ const id = crypto.randomUUID();
59
+ const a = ambasada();
60
+ await a.store.set(`order:${id}`, { paid: false });
61
+ const session = await a.checkout(
62
+ {
63
+ amount: 2,
64
+ description: "Your name as a folk poem", // public in the ledger: no personal data
65
+ success_url: `${origin}/thanks?session={CHECKOUT_SESSION_ID}`,
66
+ cancel_url: `${origin}/`,
67
+ client_reference_id: id,
68
+ },
69
+ { idempotencyKey: id },
70
+ );
71
+ return Response.redirect(session.url, 303);
28
72
  }
29
73
  ```
30
74
 
75
+ Or as a server action:
76
+
31
77
  ```ts
32
- // app/api/village-webhook/route.ts
33
- import { village } from "@ambasada/sdk";
78
+ "use server";
79
+ import { redirect } from "next/navigation";
80
+ import { ambasada } from "../../../lib/ambasada.ts";
81
+
82
+ export async function buy() {
83
+ const session = await ambasada().checkout({ amount: 2, description: "Your name as a folk poem", success_url: "…" });
84
+ redirect(session.url);
85
+ }
86
+ ```
87
+
88
+ ## Deliver: the webhook
89
+
90
+ ```ts
91
+ // app/api/webhook/route.ts (`ambasada webhook set https://<slug>.hackaton.ambasada.id/api/webhook`)
92
+ import { ambasada } from "../../../lib/ambasada.ts";
34
93
 
35
94
  export async function POST(req: Request) {
95
+ const a = ambasada();
96
+ let event;
36
97
  try {
37
- const event = await village.verifyWebhook(req); // reads the raw body itself
38
- if (event.type === "checkout.paid") await fulfil(event.data.session.client_reference_id, event.id);
39
- return new Response("ok");
98
+ event = await a.verifyWebhook(req); // reads the raw body itself
40
99
  } catch {
41
100
  return new Response("bad signature", { status: 400 });
42
101
  }
102
+ if (event.type === "checkout.paid") {
103
+ const key = `order:${event.data.session.client_reference_id}`;
104
+ const order = (await a.store.get(key)) as { paid: boolean } | null;
105
+ if (order && !order.paid) await a.store.set(key, { ...order, paid: true }); // retries reuse event.id
106
+ }
107
+ return new Response("ok"); // 2xx within 10 s, or it is retried
43
108
  }
44
109
  ```
45
110
 
111
+ ## The thanks page: check on the server
112
+
46
113
  ```tsx
47
- // app/thanks/page.tsx: confirm on the server, not from the URL alone
48
- import { village } from "@ambasada/sdk";
114
+ // app/thanks/page.tsx
115
+ import { ambasada } from "../../lib/ambasada.ts";
116
+
49
117
  export default async function Thanks({ searchParams }: { searchParams: Promise<{ session?: string }> }) {
50
118
  const { session } = await searchParams;
51
- const s = session ? await village.getCheckout(session) : null;
52
- return <h1>{s?.status === "paid" ? "Thank you!" : "Payment pending…"}</h1>;
119
+ const s = session ? await ambasada().getCheckout(session) : null;
120
+ if (s?.status !== "paid") return <h1>Payment pending… refresh in a moment.</h1>;
121
+ return <h1>Thank you! Paid {s.amount} TON.</h1>;
53
122
  }
54
123
  ```
124
+
125
+ ## pay.js receipts from the browser
126
+
127
+ ```ts
128
+ // app/api/receipt/route.ts (POST { receipt } from a `village:paid` event)
129
+ import { ambasada } from "../../../lib/ambasada.ts";
130
+ import { isVillageError } from "@ambasada/sdk";
131
+
132
+ export async function POST(req: Request) {
133
+ try {
134
+ const { receipt } = (await req.json()) as { receipt?: string };
135
+ const claims = await ambasada().verifyCheckoutReceipt(receipt);
136
+ return Response.json({ ok: true, amount: claims.amount, session: claims.session });
137
+ } catch (error) {
138
+ if (isVillageError(error)) return error.toResponse();
139
+ return Response.json({ error: { code: "INVALID_REQUEST", message: "body must be { receipt }" } }, { status: 400 });
140
+ }
141
+ }
142
+ ```
143
+
144
+ The button side is in [static-payjs.md](static-payjs.md). In Next, the template's `app/page.tsx` loads it
145
+ with `<Script src={`${villageUrl()}/pay.js`} />` (`next/script`), so the same page works against the mock.
146
+
147
+ ## Not on Cloudflare
148
+
149
+ Next on Vercel or a Node server works with the same route handlers: replace `ambasada()` with the
150
+ default client, `import { village } from "@ambasada/sdk"`, which reads `process.env`. Then `ambasada link <url>`.
@@ -0,0 +1,159 @@
1
+ # Other stacks: host it yourself, then `ambasada link`
2
+
3
+ For power users who already have a server. The Village does not host these: run them anywhere with a
4
+ public HTTPS URL, then `ambasada link https://your.server` (it rewrites `product.url` in
5
+ `ambasada.config.ts` and lists your offerings there). Agents and people pay the same way; only the code differs.
6
+
7
+ Every stack needs the same four things: a paid endpoint that verifies `x-village-receipt` on the **raw
8
+ body**, a Checkout route, a webhook route that verifies the signature on the **raw body**, and the env
9
+ `AMBASADA_API_KEY`, `AMBASADA_COMPANY`, `AMBASADA_WEBHOOK_SECRET` (plus
10
+ `AMBASADA_API_URL=http://127.0.0.1:8788` against `ambasada dev --mock-only`).
11
+
12
+ ## Express (Node)
13
+
14
+ `npm i @ambasada/sdk express`. The default `village` client reads `process.env`.
15
+
16
+ ```ts
17
+ import express from "express";
18
+ import { isVillageError, village } from "@ambasada/sdk";
19
+
20
+ const app = express();
21
+ const raw = express.raw({ type: "*/*", limit: "1mb" }); // receipts and webhooks are signed over the raw bytes
22
+
23
+ // An offering agents pay for.
24
+ app.post("/api/translate", raw, async (req, res) => {
25
+ try {
26
+ const { input, buyer, amount } = await village.verifyReceipt(req, { offering: "translate" });
27
+ const text = await village.llm(`Translate to ${input.to}. Reply with the translation only.\n\n${input.text}`);
28
+ res.json({ text }); // 2xx = you get paid; any error status = buyer refunded
29
+ } catch (e) {
30
+ if (isVillageError(e)) return res.status(e.status || 500).json(e.toJSON()); // 402 when unpaid
31
+ throw e;
32
+ }
33
+ });
34
+
35
+ // People pay.
36
+ app.post("/buy", express.json(), async (req, res) => {
37
+ const session = await village.checkout({
38
+ amount: 3,
39
+ description: "Coffee for the team",
40
+ success_url: "https://my.app/thanks?session={CHECKOUT_SESSION_ID}",
41
+ });
42
+ res.redirect(303, session.url);
43
+ });
44
+
45
+ app.post("/village-webhook", raw, async (req, res) => {
46
+ try {
47
+ const event = await village.verifyWebhook(req);
48
+ if (event.type === "checkout.paid") await fulfil(event.data.session, event.id);
49
+ res.sendStatus(200);
50
+ } catch {
51
+ res.sendStatus(400);
52
+ }
53
+ });
54
+
55
+ app.listen(3000);
56
+ ```
57
+
58
+ Don't put `app.use(express.json())` in front of the paid routes: once the body is parsed the bytes are gone
59
+ and `verifyReceipt` refuses with a `CONFIG` error. Full example: `examples/express-seller` in the village repo.
60
+
61
+ ## Cloudflare Worker without a framework
62
+
63
+ The `worker` template (`npm create ambasada@latest my-shop -- --template worker`) is this, with Village
64
+ hosting by `ambasada deploy`. By hand, in your own account (`wrangler secret put AMBASADA_API_KEY`, …):
65
+
66
+ ```ts
67
+ import { createAmbasada, type Village } from "@ambasada/sdk";
68
+
69
+ let village: Village | undefined; // one per isolate: it caches receipt keys and remembers used receipts
70
+
71
+ export default {
72
+ async fetch(req: Request, env: Record<string, string>): Promise<Response> {
73
+ village ??= createAmbasada({ env }); // AMBASADA_API_KEY, AMBASADA_API_URL, AMBASADA_COMPANY, AMBASADA_WEBHOOK_SECRET
74
+ const { pathname } = new URL(req.url);
75
+ if (pathname === "/api/translate") {
76
+ return village.sell("translate", async ({ text, to }) => ({ text: await village.llm(`Translate to ${to}: ${text}`) }))(req);
77
+ }
78
+ if (pathname === "/village-webhook") {
79
+ const event = await village.verifyWebhook(req);
80
+ if (event.type === "checkout.paid") { /* fulfil event.data.session */ }
81
+ return new Response("ok");
82
+ }
83
+ return new Response("not found", { status: 404 });
84
+ },
85
+ };
86
+ ```
87
+
88
+ Workers have no `process.env` by default, hence `createAmbasada({ env })`. Bun, Deno, Hono, Astro endpoints
89
+ (`output: "server"`) and SvelteKit use the same fetch-style handlers: `village.sell(name, handler)` is a
90
+ `(req: Request) => Promise<Response>`.
91
+
92
+ ## FastAPI (or any language over plain HTTP)
93
+
94
+ `pip install fastapi uvicorn httpx pyjwt[crypto] standardwebhooks`
95
+
96
+ ```python
97
+ import hashlib, json, os, httpx, jwt
98
+ from fastapi import FastAPI, Request, HTTPException
99
+ from standardwebhooks.webhooks import Webhook
100
+
101
+ API = os.environ.get("AMBASADA_API_URL", "https://hackaton.ambasada.id")
102
+ KEY = os.environ["AMBASADA_API_KEY"]
103
+ COMPANY = os.environ["AMBASADA_COMPANY"] # your company id
104
+ HEADERS = {"Authorization": f"Bearer {KEY}"}
105
+ app = FastAPI()
106
+ _keys = {k["kid"]: jwt.PyJWK(k) for k in httpx.get(f"{API}/.well-known/jwks.json").json()["keys"]}
107
+ _seen: set[str] = set()
108
+
109
+ def verify_receipt(token: str | None, body: bytes, offering: str) -> dict:
110
+ if not token:
111
+ raise HTTPException(402, "payment required")
112
+ key = _keys.get(jwt.get_unverified_header(token).get("kid"))
113
+ if not key:
114
+ raise HTTPException(402, "unknown village key")
115
+ try:
116
+ c = jwt.decode(token, key, algorithms=["EdDSA"], issuer="village") # checks signature + exp
117
+ except jwt.PyJWTError as e:
118
+ raise HTTPException(402, f"bad receipt: {e}")
119
+ if c.get("kind") or c["seller"] != COMPANY or c["offering"] != f"{COMPANY}/{offering}":
120
+ raise HTTPException(402, "receipt is for another offering")
121
+ if hashlib.sha256(body).hexdigest() != c["body_sha256"] or c["call_id"] in _seen:
122
+ raise HTTPException(402, "receipt does not match this request")
123
+ _seen.add(c["call_id"])
124
+ return c
125
+
126
+ def llm(prompt: str) -> str:
127
+ r = httpx.post(f"{API}/v1/llm/chat", headers=HEADERS, timeout=60,
128
+ json={"model": "deepseek-v4.1-flash", "messages": [{"role": "user", "content": prompt}]})
129
+ r.raise_for_status()
130
+ return r.json()["choices"][0]["message"]["content"]
131
+
132
+ @app.post("/api/translate") # offering endpoint agents pay for
133
+ async def translate(request: Request):
134
+ body = await request.body() # raw bytes first
135
+ verify_receipt(request.headers.get("x-village-receipt"), body, "translate")
136
+ data = json.loads(body)
137
+ return {"text": llm(f"Translate to {data['to']}. Reply with the translation only.\n\n{data['text']}")}
138
+
139
+ @app.post("/buy") # people pay
140
+ def buy():
141
+ s = httpx.post(f"{API}/v1/checkout/sessions", headers=HEADERS, json={
142
+ "amount": 3, "description": "Coffee for the team",
143
+ "success_url": "https://my.app/thanks?session={CHECKOUT_SESSION_ID}"}).json()
144
+ return {"url": s["url"]}
145
+
146
+ @app.post("/village-webhook")
147
+ async def webhook(request: Request):
148
+ body = await request.body()
149
+ try:
150
+ event = Webhook(os.environ["AMBASADA_WEBHOOK_SECRET"]).verify(body, dict(request.headers))
151
+ except Exception:
152
+ raise HTTPException(400, "bad signature")
153
+ if event["type"] == "checkout.paid":
154
+ fulfil(event["data"]["session"], request.headers["webhook-id"])
155
+ return "ok"
156
+ ```
157
+
158
+ Buy from another company: `httpx.post(f"{API}/v1/pay/inkwell/poem", headers={**HEADERS, "Idempotency-Key": str(uuid4()), "x-village-max-price": "2"}, json=input)`.
159
+ Errors come back as `{"error": {"code": …}}` with the HTTP status. Go, PHP, Ruby: same calls.
@@ -1,5 +1,6 @@
1
- # Astro, or any static page: pay.js (no backend)
1
+ # A static page: pay.js (no backend)
2
2
 
3
+ For plain HTML, Astro, a page built in Lovable, Bolt or v0, Carrd, anything that can include a script.
3
4
  pay.js is served by the village (`https://hackaton.ambasada.id/pay.js`; `ambasada dev` serves the same script at
4
5
  `http://127.0.0.1:8788/pay.js`, and it talks to whichever origin it was loaded from). It turns a button into
5
6
  "Pay with TON" using a **payment link**, so the page never needs your key.
@@ -11,7 +12,7 @@ ambasada links create --amount 3 --description "A coffee for the team" --success
11
12
  # → pl_… (or POST /v1/checkout/links with curl)
12
13
  ```
13
14
 
14
- 2. On the page (`src/pages/index.astro` or plain HTML):
15
+ 2. On the page (plain HTML, `index.html`, or `src/pages/index.astro`):
15
16
 
16
17
  ```html
17
18
  <script src="https://hackaton.ambasada.id/pay.js" defer></script>
@@ -43,13 +44,15 @@ with `cancelled` or `closed`), and `await Village.session("cs_…")` → `{ stat
43
44
  purchase matters (a download, credits), send `e.detail.receipt` to a server or edge function and verify it:
44
45
 
45
46
  ```ts
46
- // e.g. an Astro endpoint src/pages/api/unlock.ts (output: "server"), or a Worker
47
- import { village } from "@ambasada/sdk";
48
- export const POST = async ({ request }: { request: Request }) => {
47
+ // any fetch-style server route: the templates' /api/receipt, an Astro endpoint, a Worker
48
+ import { village } from "@ambasada/sdk"; // reads process.env; on a Worker: createAmbasada({ env })
49
+ export async function POST(request: Request) {
49
50
  const { receipt } = await request.json();
50
51
  const claims = await village.verifyCheckoutReceipt(receipt); // throws if forged / for another company
51
52
  return Response.json({ ok: true, amount: claims.amount });
52
- };
53
+ }
53
54
  ```
54
55
 
55
- Or set up the `checkout.paid` webhook (see reference/checkout.md).
56
+ Or set up the `checkout.paid` webhook ([reference/checkout.md](../reference/checkout.md)).
57
+
58
+ Then tell the village where the page lives: `ambasada link https://your.page`.
@@ -30,8 +30,8 @@ await village.listings.put("translate", {
30
30
  ```
31
31
 
32
32
  Schema field types: `string`, `text`, `number`, `integer`, `boolean`, `object`, `array`, `json`,
33
- `image_url`, `file_url`; a trailing `?` makes a field optional. In the template, offerings live in
34
- `product/offerings.ts` and `ambasada offerings sync` pushes them.
33
+ `image_url`, `file_url`; a trailing `?` makes a field optional. In a project, offerings live in the file
34
+ that `ambasada.config.ts` points to (`offerings:`); `ambasada offerings sync` and `ambasada deploy` push them.
35
35
 
36
36
  ## Utilities
37
37
 
@@ -43,14 +43,31 @@ const res = await village.llm.chat({ model: "deepseek-v4-pro", messages }); //
43
43
  const { url } = await village.images.generate({ prompt: "a lighthouse", model: "nano_banana" });
44
44
  const tools = await village.tools.list(); // curated catalog, TON prices
45
45
  const { output } = await village.tools.call("web.search", { query: "Mamaia weather" });
46
- const file = await village.files.put(bytes, { contentType: "image/png" }); // public URL for other products
46
+ const file = await village.files.put(bytes, { contentType: "image/png" }); // { id, url }: public URL, 0.1 TON
47
47
  ```
48
48
 
49
49
  HTTP: `POST /v1/llm/chat` (OpenAI body), `POST /v1/images { model, prompt }` → `{ url, cost }`,
50
- `GET /v1/tools`, `POST /v1/tools/:id` → `{ output, cost, call_id }`, `POST /v1/files`.
50
+ `GET /v1/tools`, `POST /v1/tools/:id` → `{ output, cost, call_id }`, `POST /v1/files` (raw body +
51
+ content-type, or `{ url }`) → `{ id, url, cost }`.
51
52
  LLM models: `deepseek-v4.1-flash` (default, cheap), `deepseek-v4-pro`, `glm-5.3-flash`.
52
53
  Images: `nano_banana` (2 TON), `gpt-image-2` (8 TON).
53
54
 
55
+ ## Store and hosting
56
+
57
+ `village.store` is your company's JSON key-value store: free, 1 MB per company (keys + values), so a
58
+ hosted product needs no database. `ambasada deploy` hosts the product at `https://<slug>.ambasada.dev`
59
+ with `AMBASADA_API_KEY`, `AMBASADA_API_URL`, `AMBASADA_COMPANY` and `AMBASADA_WEBHOOK_SECRET` already set.
60
+
61
+ ```ts
62
+ await village.store.set("cart:ana", { items: ["translate"] }); // any JSON value
63
+ const cart = await village.store.get<{ items: string[] }>("cart:ana"); // null when not set
64
+ await village.store.delete("cart:ana");
65
+ const { keys, used_bytes } = await village.store.list("cart:");
66
+ ```
67
+
68
+ HTTP: `GET|PUT|DELETE /v1/store/:key` (PUT body = the JSON value), `GET /v1/store?prefix=`;
69
+ `POST /v1/product/deploy` (what `ambasada deploy` calls), `GET /v1/product`, `DELETE /v1/product`.
70
+
54
71
  ## Errors
55
72
 
56
73
  `{ "error": { "code", "message", call_id?, charged?: false, refunded?, approval_id? } }`. The SDK throws
@@ -48,7 +48,7 @@ export const POST = village.sell("post_kit", async ({ topic }, call) => {
48
48
 
49
49
  Any JWT library with EdDSA (Ed25519): fetch `GET /.well-known/jwks.json`, pick the key by `kid`, verify,
50
50
  then check `exp`, `iss == "village"`, `seller`, `offering`, and `sha256(raw body) == body_sha256` (hex).
51
- See the FastAPI recipe for a complete example.
51
+ Complete example: [the FastAPI recipe](../recipes/other-stacks.md#fastapi-or-any-language-over-plain-http).
52
52
 
53
53
  ## Checkout receipts
54
54
 
@@ -5,11 +5,14 @@ signatures as production, so your code does not change between test and live: on
5
5
  `AMBASADA_API_KEY` do.
6
6
 
7
7
  ```sh
8
- npx ambasada dev # in your project: registers offerings.ts pointing at product.url
9
- AMBASADA_API_URL=http://127.0.0.1:8788 AMBASADA_API_KEY=sk_test_dev npm run dev # your product
8
+ npx ambasada dev # in your project: the mock, your offerings registered, and your site's dev server
10
9
  npx ambasada dev buy translate '{"text":"Bună!","to":"en"}' # a fake agent pays you 1 TON
11
10
  ```
12
11
 
12
+ A project made by `npm create ambasada@latest` already has `AMBASADA_API_URL=http://127.0.0.1:8788` and
13
+ `AMBASADA_API_KEY=sk_test_dev` in its local env file. Running your site yourself? `npx ambasada dev --mock-only`,
14
+ and start it with those two variables set.
15
+
13
16
  What it gives you:
14
17
 
15
18
  - **Fake bank**: your company starts with 500 TON; `fake-agent` (key `sk_test_agent`) has 100; the fake
@@ -17,7 +20,7 @@ What it gives you:
17
20
  - **Real Pay proxy**: holds, calls your endpoint with a signed `x-village-receipt` (keys at
18
21
  `/.well-known/jwks.json`), captures on 2xx, refunds on failure, checks `input`/`output` schemas.
19
22
  - **Checkout**: `village.checkout()` returns a `/pay/cs_…` page where the fake visitor approves; the signed
20
- `checkout.paid` webhook goes to your webhook URL (secret `AMBASADA_WEBHOOK_SECRET` from `.env`).
23
+ `checkout.paid` webhook goes to your webhook URL (secret `AMBASADA_WEBHOOK_SECRET` from the local env file).
21
24
  `/pay.js` is the production script itself, so buttons, redirect and popup mode, `village:paid` /
22
25
  `village:cancelled` and `GET /v1/web/checkout/:id` behave as on the real site. Webhook test, retry and
23
26
  link / session management routes are there too.
@@ -28,5 +31,6 @@ What it gives you:
28
31
  - **Agent**: `/v1/agent/config`, `/v1/agent/steer` (canned reply), `/v1/agent/transcript`.
29
32
  - **Dashboard** at `http://127.0.0.1:8788/`: wallets, your offerings with a "Buy" button, recent calls.
30
33
 
31
- Any bearer key is accepted as your company except `sk_test_agent`. Going live: `ambasada login`, put your
32
- `sk_live_…` key in the host's env, remove `AMBASADA_API_URL`, `ambasada link https://your.product`, `ambasada deploy`.
34
+ Any bearer key is accepted as your company except `sk_test_agent`. Going live: `ambasada login`, then
35
+ `ambasada deploy` (Village hosting sets the real key and URL for you). Hosting it yourself: put your `sk_live_…`
36
+ key in the host's env, leave `AMBASADA_API_URL` unset, then `ambasada link https://your.product`.
@@ -0,0 +1,60 @@
1
+ ---
2
+ name: ambasada-product
3
+ description: Build a digital product that sells in the Hack A Ton Village and put it online. Use when the user wants to make a shop, app, tool or website that people or agents pay for in TON (the Village play currency), start one with `npm create ambasada@latest`, run it with `ambasada dev`, publish it with `ambasada deploy`, check it is ready to sell, or bring a site built in ChatGPT, Lovable, Bolt or v0 into the Village with `ambasada link`. Default stack: Next.js on Cloudflare.
4
+ ---
5
+
6
+ # Build a product that sells in the Village
7
+
8
+ The Village is the Hack A Ton market. Everything is paid in **TON**: a play currency held by the Village
9
+ bank, **not** the TON blockchain. Visitors spend TON from their Ambasada ID wallet; other teams' agents
10
+ buy from you through the Gateway.
11
+
12
+ A product is **one website** (one Cloudflare Worker) at `https://<your-slug>.hackaton.ambasada.id`, plus
13
+ **your agent**, which runs the shop (prices, suppliers, deals) while you sleep.
14
+
15
+ For payment details (Checkout, receipts, webhooks, `buy`/`need`, LLM and images) use the
16
+ **ambasada-payments** skill. This skill is about the product around them.
17
+
18
+ ## Start
19
+
20
+ ```sh
21
+ npm create ambasada@latest my-shop # Next.js (default)
22
+ cd my-shop && npm install
23
+ npx ambasada dev # fake bank + your site, no login, fake money
24
+ ```
25
+
26
+ Other templates: add `-- --template spa` (Vite + React; for code exported from Lovable or Bolt) or
27
+ `-- --template worker` (plain HTML + small fetch handlers; the smallest possible product).
28
+ All three have the same `ambasada.config.ts`, `agent/` folder and four endpoints: `checkout`, `webhook`,
29
+ `receipt` and the paid offering `translate`.
30
+
31
+ ## What to read
32
+
33
+ | The user wants to… | Read |
34
+ |---|---|
35
+ | know where files go, what Next.js features are allowed, where to keep data and secrets | [reference/stack.md](reference/stack.md) |
36
+ | run it locally, put it online, use their own Cloudflare account | [reference/dev-and-deploy.md](reference/dev-and-deploy.md) |
37
+ | make it look good and sell (one page, one offer) | [reference/design.md](reference/design.md) |
38
+ | build in ChatGPT, Lovable, Bolt or v0 and sell from there | [reference/build-elsewhere.md](reference/build-elsewhere.md) |
39
+ | know if it is ready | [checklists/sellable.md](checklists/sellable.md) |
40
+
41
+ ## Rules (break one and the product does not work in the Village)
42
+
43
+ 1. **One product = one Worker.** No separate backend, no own database, no Docker. Data goes in
44
+ `ambasada.store` (small JSON) or `ambasada.files` (images, PDFs). See [reference/stack.md](reference/stack.md#where-data-lives).
45
+ 2. **Only the allowed Next.js subset**: pages, server components, route handlers, server actions, `fetch`.
46
+ No `next/image` default loader, no ISR/`revalidate`, no `fs`/`child_process`/native packages, no custom
47
+ server, no `middleware.ts`/`proxy.ts`, no `runtime = "edge"`. See [reference/stack.md](reference/stack.md#nextjs-allowed-and-banned).
48
+ 3. **The API key never reaches the browser.** It lives in `.env` locally (or `.dev.vars`); only server code reads it.
49
+ Never prefix it with `NEXT_PUBLIC_`.
50
+ 4. **Deliver only after payment is proven on the server**: webhook, `getCheckout(id).status === "paid"`,
51
+ or a verified receipt. Never because the URL says `?session=…`.
52
+ 5. **Sellable means**: a landing page with the price and a Pay button above the fold, one offering
53
+ with a receipt-checked endpoint, a webhook that delivers, the agent configured. Run
54
+ [checklists/sellable.md](checklists/sellable.md) before saying "done".
55
+
56
+ ## When the user is not a developer
57
+
58
+ Do the work; don't ask them to edit code. Ask at most: what they sell, to whom, for how many TON.
59
+ Then build one page, run `npx ambasada dev`, open it, pay as the fake visitor, and show them it worked.
60
+ Explain in one sentence per step, no jargon.
@@ -0,0 +1,51 @@
1
+ # Sellable checklist
2
+
3
+ Run it before saying "done". Check each line yourself; don't assume. Every line needs `npx ambasada dev`
4
+ running, except the last section.
5
+
6
+ ## The page
7
+
8
+ - [ ] `/` on a phone-sized window (375 px wide) shows, without scrolling: what it is, the price in TON, the Pay button.
9
+ - [ ] The price on the page equals the price charged (the Checkout `amount`, or the offering's `price`).
10
+ - [ ] One offer. Any second offer has its own price next to its own button.
11
+ - [ ] No login, no signup, no "coming soon", no lorem ipsum, no broken images.
12
+
13
+ ## People can pay
14
+
15
+ - [ ] Pressing Pay opens the Village approve page (`/pay/cs_…` or `/pay/pl_…`); approving as the fake visitor works.
16
+ - [ ] After paying, `/thanks` shows the result, or "working on it" and then the result.
17
+ - [ ] `/thanks?session=` with a made-up or unpaid session does **not** show the result.
18
+ - [ ] The webhook route verifies the signature (`verifyWebhook`) and delivers on `checkout.paid`; a
19
+ repeated event (same `webhook-id`) does not deliver twice.
20
+ - [ ] The Checkout `description` has no personal data (it is public in the ledger).
21
+
22
+ ## Agents can pay
23
+
24
+ - [ ] At least one offering in the offerings list that `ambasada.config.ts` points to, with
25
+ `description`, `price`, `endpoint`, `input` and `output`.
26
+ - [ ] Its route uses `sell(...)` or `verifyReceipt(...)`: a plain `curl -X POST` to it gets `402`.
27
+ - [ ] `npx ambasada dev buy <offering> '<json input>'` succeeds and the output matches `output`.
28
+ - [ ] On failure it answers non-2xx (the buyer is refunded), never 200 with an error inside.
29
+
30
+ ## Safe
31
+
32
+ - [ ] `grep -rn "sk_live_\|sk_test_\|whsec_" app components lib public src web` finds nothing
33
+ (keys live only in `.env` / `.dev.vars`), and nothing is named `NEXT_PUBLIC_*KEY*`.
34
+ - [ ] `.dev.vars` and `.env*` are in `.gitignore`.
35
+ - [ ] No banned Next.js features: `grep -rn "next/image\|revalidate\|runtime = \"edge\"\|from \"fs\"\|child_process" app lib`
36
+ finds nothing (or only `images: { unoptimized: true }` in `next.config.ts`).
37
+
38
+ ## The agent
39
+
40
+ - [ ] `agent/agent.md` says what you sell, your price range (minimum and maximum) and what the agent may buy.
41
+ - [ ] `agent/settings.json` `limits` are sane for your wallet (defaults: 10 TON per payment, 100 per day,
42
+ 5 automatic per payment).
43
+
44
+ ## Online (after `npx ambasada deploy`)
45
+
46
+ - [ ] `https://<slug>.hackaton.ambasada.id` loads on a phone.
47
+ - [ ] `npx ambasada offerings list`: every endpoint starts with your public URL, none with `localhost`.
48
+ - [ ] `npx ambasada webhook test` prints `delivered`.
49
+ - [ ] One real payment by someone outside your team (members can't buy from their own company) shows up
50
+ in `npx ambasada ledger`, and they got what they paid for.
51
+ - [ ] `npx ambasada logs --agent` shows the agent woke up.
@@ -0,0 +1,82 @@
1
+ # Built in ChatGPT, Lovable, Bolt or v0
2
+
3
+ Three ways, easiest first. Replace `pl_XXXX`, prices and names with yours.
4
+
5
+ ## A. Keep it hosted there, add a Pay button
6
+
7
+ Good for: a page that sells something simple (a poster, a reading, a coffee for the team).
8
+
9
+ 1. On a laptop with Node 22+, once:
10
+
11
+ ```sh
12
+ npm create ambasada@latest my-shop # you need its ambasada.config.ts and agent/ folder
13
+ cd my-shop && npm install
14
+ npx ambasada login
15
+ npx ambasada links create --amount 2 --description "Your name as a folk poem" --success-url https://YOUR-SITE/thanks
16
+ # → pl_XXXX
17
+ ```
18
+
19
+ 2. Paste this into the builder's chat:
20
+
21
+ > Add a "Pay 2 TON" button to the top of the home page, visible without scrolling on a phone.
22
+ > Load `<script src="https://hackaton.ambasada.id/pay.js" defer></script>` on every page (in the HTML head
23
+ > or index.html). Render the button as `<button data-village-pay="pl_XXXX">Pay 2 TON</button>`.
24
+ > Make a `/thanks` page that also loads pay.js. Listen for the `village:paid` event on `document`
25
+ > (`event.detail` has `session` and `receipt`): when it fires, show the result. On `village:cancelled`,
26
+ > do nothing. Do not add Stripe or any other payment provider. Do not add login.
27
+
28
+ 3. Register the site with the Village:
29
+
30
+ ```sh
31
+ npx ambasada link https://YOUR-SITE
32
+ ```
33
+
34
+ It warns if `https://YOUR-SITE/health` doesn't answer; for a page with only a Pay button that's fine.
35
+ It also lists the template's `translate` offering at your site, which has no such endpoint: delete it
36
+ from the offerings list first, or add `--no-sync`.
37
+
38
+ Limit: anything unlocked only in the browser can be unlocked by anyone who reads the page's code. Fine for
39
+ a thank-you and a small gift; for downloads, credits or paid AI work, use B or C.
40
+
41
+ ## B. Bring the code home (recommended for Lovable and Bolt)
42
+
43
+ Good for: anything with real work behind the button. You get Village hosting and the full rules.
44
+
45
+ 1. Export the project to GitHub (Lovable: GitHub sync; Bolt: download or GitHub) and clone it.
46
+ 2. `npm create ambasada@latest my-shop -- --template spa` and copy the exported `src/` (components,
47
+ pages, styles) into the new project's `src/`, replacing the starter UI. Keep the template's `api/`,
48
+ `agent/`, `ambasada.config.ts`, `wrangler.jsonc` and `vite.config.ts`.
49
+ 3. Open the project in Claude Code, Cursor or Codex and say:
50
+
51
+ > This is a Village product (see .claude/skills/ambasada-product). Wire the Pay button in my UI to the
52
+ > template's checkout endpoint, remove Supabase/Firebase and any login, store data with ambasada.store,
53
+ > then run the sellable checklist.
54
+
55
+ 4. `npx ambasada dev`, test a payment, then `npx ambasada deploy`.
56
+
57
+ ## C. ChatGPT (or any chat) writes the code, you paste it
58
+
59
+ 1. `npm create ambasada@latest my-shop`, `cd my-shop && npm install`, `npx ambasada dev`.
60
+ 2. Paste this into ChatGPT, then the two files it mentions, then your idea:
61
+
62
+ > I have a Next.js 16 App Router project that runs on Cloudflare Workers (OpenNext). Rules: no
63
+ > database, no `fs`, no native packages, no `next/image` (use `<img>`), no middleware, no `revalidate`,
64
+ > no login. Server code gets the payments client from `lib/ambasada.ts`; it never runs in the browser.
65
+ > Below are my `app/page.tsx` and `app/api/checkout/route.ts`. Keep the checkout route and call it the
66
+ > way the current page does. Rewrite `app/page.tsx` for my idea: what I sell, the price in TON and
67
+ > the Pay button, all visible on a phone without scrolling. After paying, the buyer lands on
68
+ > `app/thanks/page.tsx`; keep its check that the session is `"paid"` before showing the result.
69
+ > Write complete files, one code block per file, with the file path above each.
70
+ > My idea: …
71
+
72
+ 3. Paste each file where ChatGPT says, keep `npx ambasada dev` running, check the page, pay as the fake
73
+ visitor. When it works: `npx ambasada deploy`.
74
+
75
+ Use ChatGPT to write files, not to decide the stack: if its answer adds Prisma, Supabase, NextAuth,
76
+ `next/image` or a `middleware.ts`, tell it "that's banned in my project, do it without".
77
+
78
+ ## Connect your AI tool to the Village (optional)
79
+
80
+ The Village MCP server (`https://hackaton.ambasada.id/mcp`) lets Claude, ChatGPT, Cursor or Lovable read
81
+ your wallet, create offerings and set up your agent while you build. Setup per tool: `docs/CONNECT.md`
82
+ in the Village repo. It is for building only; your published site never uses it.
@@ -0,0 +1,51 @@
1
+ # Design for people who pay in 10 seconds
2
+
3
+ Visitors open your product on a phone, inside the Village app, between two other stands. They decide
4
+ in seconds. Build for that.
5
+
6
+ ## One page, one offer
7
+
8
+ - **One page.** Everything a buyer needs fits on the landing page. Extra pages only for `/thanks` and
9
+ maybe one result page.
10
+ - **One offer.** One thing, one price. "Your name as a Romanian folk poem, 2 TON". Not a menu of six.
11
+ Add a second offer only after the first one sells.
12
+ - **Above the fold, on a phone (375 × 667):** what it is (one line), the price in TON, the Pay button.
13
+ Nothing to scroll past, no signup, no cookie banner.
14
+ - **Few words.** A headline of up to 8 words, one sentence under it, the button. Cut every adjective
15
+ you can.
16
+ - **Show the result.** One example of what the buyer gets (an image, a sample text) beats any description.
17
+
18
+ ## The button
19
+
20
+ - Label it with the action and the price: **Pay 2 TON**, or **Get my poem: 2 TON**.
21
+ - Big (at least 48 px tall), full width on phones, one strong colour, nothing else in that colour.
22
+ - If the buyer must type something first (a name, a photo), put that one field right above the button.
23
+ - After paying, land on `/thanks` with the result, or "Working on it…" and the result appearing when ready.
24
+ Never a blank page.
25
+
26
+ ## Look
27
+
28
+ - Plain CSS is enough. One font (the system font is fine), two colours plus grey, generous spacing.
29
+ - Large text: 18 px body on phones, a headline twice that.
30
+ - Dark text on a light background unless your idea needs otherwise.
31
+ - Works with one thumb: tap targets 48 px, no hover-only menus.
32
+ - Loads fast: no video backgrounds, no 3 MB hero images, no animation libraries.
33
+
34
+ ## Words
35
+
36
+ - Say what they get, not how it works. "A logo for your stand in 1 minute", not "AI-powered logo generation".
37
+ - Prices are TON, written as `2 TON`. TON is the Village play currency; never call it crypto.
38
+ - Write in the language of your buyers. At Hack A Ton that is often Romanian and English; one is fine.
39
+
40
+ ## Before and after
41
+
42
+ ```
43
+ Bad: Welcome to PoemForge™, the next-generation AI-powered creative writing platform
44
+ [Features] [Pricing] [About] [Sign up] …scroll… [Get started]
45
+
46
+ Good: Your name as a folk poem
47
+ Type a name. Get a 4-line Romanian folk poem in 10 seconds.
48
+ [ Name: _________ ]
49
+ [ Get my poem: 2 TON ]
50
+ "Ana, floare de la munte, / …" ← one real example
51
+ ```
@@ -0,0 +1,105 @@
1
+ # Run it, put it online
2
+
3
+ ## `ambasada dev`: everything on your laptop, fake money
4
+
5
+ ```sh
6
+ npx ambasada dev
7
+ ```
8
+
9
+ Starts two things:
10
+
11
+ 1. a **mock Village** on `http://127.0.0.1:8788`: same routes and signatures as the real one, a fake bank
12
+ (your company 500 TON, the fake visitor `p_visitor` 100, `fake-agent` 100), the real `pay.js`, and an
13
+ LLM that answers `[mock] …`. Open it in a browser for a dashboard: wallets, offerings, calls.
14
+ 2. **your site** with its normal dev server (`next dev` for the `next` template), pointed at the mock.
15
+
16
+ It registers your offerings with the mock. No login, no real TON.
17
+
18
+ Try a sale:
19
+
20
+ - **as a person**: open your site, press Pay, approve as the fake visitor, land on `/thanks`.
21
+ - **as an agent**: `npx ambasada dev buy translate '{"text":"Bună!","to":"en"}'` (the fake agent pays 1 TON).
22
+
23
+ `npx ambasada dev --mock-only` starts only the mock, if you run your site yourself. The mock's keys:
24
+ `AMBASADA_API_URL=http://127.0.0.1:8788`, `AMBASADA_API_KEY=sk_test_dev` (already in the project's `.env`).
25
+ More on the mock: the ambasada-payments skill, `reference/test-mode.md`.
26
+
27
+ ## `ambasada deploy`: online at `<slug>.hackaton.ambasada.id`
28
+
29
+ Once, per laptop:
30
+
31
+ ```sh
32
+ npx ambasada login # opens a page: sign in with Ambasada ID, pick your company
33
+ ```
34
+
35
+ (If it asks for a key instead: the Village app → your team → Developers → Server keys, `sk_live_…`.)
36
+
37
+ Then, every time you want the new version online:
38
+
39
+ ```sh
40
+ npx ambasada deploy
41
+ ```
42
+
43
+ It does, in order:
44
+
45
+ 1. **build**: `opennextjs-cloudflare build` (Next), `vite build` (spa) or a bundle of the Worker (worker);
46
+ 2. **smoke test**: runs the built Worker locally in workerd and requests `/` and `/api/receipt`;
47
+ a crash stops the deploy (skip with `--no-smoke` only if you know why it fails);
48
+ 3. **upload** to the Village's hosting: the URL is `https://<your-company-slug>.hackaton.ambasada.id`,
49
+ with `AMBASADA_API_KEY`, `AMBASADA_API_URL` and `AMBASADA_COMPANY` set for you;
50
+ 4. **agent and offerings**: pushes `agent/agent.md` + `agent/settings.json`, and lists your offerings at the new URL.
51
+
52
+ No Cloudflare account needed. Options: `--agent-only` (push only `agent/`), `--offerings-only` (only the
53
+ offerings list), `--dry-run` (print what it would push).
54
+
55
+ After the first deploy, point the webhook at it and deploy again so the Worker gets the secret:
56
+
57
+ ```sh
58
+ npx ambasada webhook set https://<slug>.hackaton.ambasada.id/api/webhook
59
+ npx ambasada webhook test # should print "delivered"
60
+ ```
61
+
62
+ Limits on Village hosting: 10 MB compressed bundle, 1000 static files, short CPU time per request
63
+ (waiting on `fetch` or the LLM does not count). Heavy work belongs in an API you call, not in the Worker.
64
+
65
+ ## `ambasada deploy --own`: your own Cloudflare account
66
+
67
+ Runs `wrangler deploy` into your account (you need `npx wrangler login` first), then
68
+ `ambasada link <your workers.dev URL>`. Set the secrets yourself:
69
+
70
+ ```sh
71
+ npx wrangler secret put AMBASADA_API_KEY
72
+ npx wrangler secret put AMBASADA_COMPANY
73
+ npx wrangler secret put AMBASADA_WEBHOOK_SECRET
74
+ ```
75
+
76
+ ## `ambasada link`: the product is hosted somewhere else
77
+
78
+ ```sh
79
+ npx ambasada link https://my-thing.lovable.app
80
+ ```
81
+
82
+ Sets `product.url` in `ambasada.config.ts`, checks that `<url>/health` answers, and lists your offerings
83
+ at that URL (relative endpoints like `/api/translate` become `<url>/api/translate`). Use it for Lovable,
84
+ Bolt, v0, Vercel, your own server. See [build-elsewhere.md](build-elsewhere.md).
85
+
86
+ ## Watch it run
87
+
88
+ ```sh
89
+ npx ambasada wallet # balance, held, available
90
+ npx ambasada ledger # every TON in and out
91
+ npx ambasada logs --agent --follow # what your agent thinks and does
92
+ npx ambasada steer "raise translate to 1.2 TON for 2 hours"
93
+ ```
94
+
95
+ ## When it goes wrong
96
+
97
+ | Symptom | Fix |
98
+ |---|---|
99
+ | `deploy` prints "Product hosting is not part of `ambasada deploy` yet" | your CLI is old: `npm i -D @ambasada/cli@latest` |
100
+ | build fails on `fs`, `child_process`, a `.node` file, `sharp` | a banned package: see [stack.md](stack.md#nextjs-allowed-and-banned) |
101
+ | smoke test: `/` answers 500 | open `npx ambasada dev`, load `/`, read the terminal error |
102
+ | `CONFIG: no API key` | missing `AMBASADA_API_KEY`: `.env` locally, `ambasada login` before deploying |
103
+ | Pay works, nothing delivered | webhook not set or wrong secret: `ambasada webhook test`, then deploy again |
104
+ | agents can't buy | `ambasada offerings list`: endpoint must be your public URL, not `localhost` |
105
+ | `402` when you call your own offering | right: offerings only answer calls that come through the Gateway with a receipt; test with `ambasada dev buy` |
@@ -0,0 +1,109 @@
1
+ # The stack: Next.js on Cloudflare
2
+
3
+ Every Village product is one Cloudflare Worker. The default template builds it from **Next.js 16.4**
4
+ with **@opennextjs/cloudflare 1.20** (the OpenNext adapter) and the `nodejs_compat` flag. You write a
5
+ normal Next.js App Router app; `ambasada deploy` turns it into a Worker.
6
+
7
+ ## Folder layout (template `next`)
8
+
9
+ ```
10
+ my-shop/
11
+ app/page.tsx the landing page: what you sell, the price, the Pay button
12
+ app/thanks/page.tsx after paying: checks the session on the server, then delivers
13
+ app/api/checkout/route.ts creates a Checkout session, redirects to the Village approve page
14
+ app/api/webhook/route.ts checkout.paid arrives here, signed: deliver here
15
+ app/api/receipt/route.ts verifies a pay.js receipt from the browser
16
+ app/api/translate/route.ts the paid offering: agents call it with a receipt
17
+ lib/ambasada.ts the Village client for server code (reads the Worker env)
18
+ offerings.ts what agents can buy: name, price, endpoint, input and output
19
+ agent/agent.md your agent's instructions, in plain English
20
+ agent/settings.json its model, how often it wakes, spending limits
21
+ ambasada.config.ts company id and name, idea, product URL, where offerings and agent live
22
+ wrangler.jsonc Worker settings (nodejs_compat, assets); no bindings needed
23
+ open-next.config.ts OpenNext settings; leave as is
24
+ .env secrets for local runs (never commit, never send to the browser)
25
+ .claude/skills/ this skill and ambasada-payments
26
+ ```
27
+
28
+ One more paid offering = one more `app/api/<name>/route.ts` plus one more entry in `offerings.ts`
29
+ (the file `ambasada.config.ts` points to with `offerings:`). `ambasada deploy` and `ambasada offerings sync` push
30
+ that list to the market. An offering name is a slug (`a-z`, `0-9`, `_`, `-`); price 0.1–500 TON.
31
+
32
+ ## Using the client
33
+
34
+ Server code only (route handlers, server components, server actions):
35
+
36
+ ```ts
37
+ // app/api/translate/route.ts
38
+ import { ambasada } from "../../../lib/ambasada.ts";
39
+
40
+ export const POST = (req: Request) =>
41
+ ambasada().sell<{ text: string; to: string }>("translate", async ({ text, to }) => ({
42
+ text: await ambasada().llm(`Translate to ${to}. Reply with the translation only.\n\n${text}`),
43
+ }))(req);
44
+ ```
45
+
46
+ `lib/ambasada.ts` creates one client per Worker instance with `createAmbasada({ env })` from `@ambasada/sdk`,
47
+ where `env` comes from `getCloudflareContext()` (deployed); under `next dev` the SDK reads `process.env`.
48
+ It reads `AMBASADA_API_KEY`, `AMBASADA_API_URL`, `AMBASADA_COMPANY`, `AMBASADA_WEBHOOK_SECRET`.
49
+ Everything the client can do is in the ambasada-payments skill.
50
+
51
+ ## Next.js: allowed and banned
52
+
53
+ Allowed, and enough for any product here:
54
+
55
+ - pages and layouts (`app/**/page.tsx`, `layout.tsx`), server components, client components (`"use client"`)
56
+ - route handlers (`app/api/**/route.ts`), server actions (`"use server"`)
57
+ - `fetch` to any HTTPS API, `next/link`, `next/font`, plain CSS / CSS modules / Tailwind
58
+
59
+ Banned (they fail on Workers or break the deploy):
60
+
61
+ | Don't | Do instead |
62
+ |---|---|
63
+ | `next/image` with the default loader | `<img>`, or `images: { unoptimized: true }` in `next.config.ts` |
64
+ | ISR, `export const revalidate = …`, `revalidatePath`, `unstable_cache` | render on each request (`export const dynamic = "force-dynamic"`), or save results in `ambasada.store` |
65
+ | `fs`, `child_process`, `sharp`, `bcrypt`, `sqlite3`, `puppeteer`, any native package | `fetch` an API; the Village sells LLM, images and web tools (`ambasada.llm`, `ambasada.images`, `ambasada.tools`) |
66
+ | a custom server (`server.js`, Express inside Next) | route handlers |
67
+ | `middleware.ts` / `proxy.ts`, `export const runtime = "edge"` | check inside the page or route handler; Next's default runtime is right |
68
+ | Prisma, Drizzle with a database, Supabase, Firebase, Mongo | `ambasada.store` and `ambasada.files` |
69
+ | writing files at runtime, global variables as storage | `ambasada.store` (each request can run on a different machine) |
70
+
71
+ Keep the bundle small: the Worker limit is 10 MB compressed. Big UI kits and icon packs are the usual cause.
72
+
73
+ ## Where data lives
74
+
75
+ No database of your own. Two primitives, free, scoped to your company:
76
+
77
+ | | For | Calls |
78
+ |---|---|---|
79
+ | `ambasada.store` | orders, credits, settings, small results: JSON, 1 MB per company in total | `get(key)`, `set(key, value)`, `delete(key)`, `list()` |
80
+ | `ambasada.files` | images, PDFs, audio you deliver | `files.put(bytes \| Blob \| url, { contentType })` → `{ url }` (public) |
81
+
82
+ ```ts
83
+ const a = ambasada();
84
+ await a.store.set(`order:${id}`, { item: "poster", paid: false });
85
+ const order = await a.store.get(`order:${id}`);
86
+ const { url } = await a.files.put(pngBytes, { contentType: "image/png" });
87
+ ```
88
+
89
+ `ambasada.store` is new: if `store` is missing on the client, update `@ambasada/sdk`.
90
+ File URLs are public: don't put private data in them.
91
+
92
+ ## Secrets
93
+
94
+ - Local: `.env` in the project (Cloudflare's `.dev.vars` works too). The template writes it with test
95
+ values for `ambasada dev`. Both are in `.gitignore`; keep them there.
96
+ - Deployed: `ambasada deploy` injects `AMBASADA_API_KEY`, `AMBASADA_API_URL` and `AMBASADA_COMPANY` for you.
97
+ With `--own`, set them yourself: `npx wrangler secret put AMBASADA_API_KEY`.
98
+ - Other services' keys (an OpenAI key, say): same place, read on the server only.
99
+ - Anything named `NEXT_PUBLIC_*` is copied into the browser. Never put a key there.
100
+ - Never paste a key into a page, a client component, a prompt for a no-code builder, or a commit.
101
+
102
+ ## Variants
103
+
104
+ - `spa`: Vite + React with `@cloudflare/vite-plugin`, for code exported from Lovable or Bolt. Same
105
+ `api/` endpoints as small fetch handlers; the React app calls them with `fetch("/api/…")`.
106
+ - `worker`: `web/` (plain HTML/CSS) plus `api/*.ts` fetch handlers. Best when the product is one form
107
+ and one button.
108
+
109
+ The rules on this page (state, secrets, no native packages) apply to all three.
@@ -1,48 +0,0 @@
1
- # Express
2
-
3
- `npm i @ambasada/sdk express` · env: `AMBASADA_API_KEY`, `AMBASADA_WEBHOOK_SECRET` (+ `AMBASADA_API_URL=http://127.0.0.1:8788` on `ambasada dev`)
4
-
5
- ```ts
6
- import express from "express";
7
- import { isVillageError, village } from "@ambasada/sdk";
8
-
9
- const app = express();
10
- const raw = express.raw({ type: "*/*", limit: "1mb" }); // receipts and webhooks are signed over the raw bytes
11
-
12
- // An offering agents pay for.
13
- app.post("/api/translate", raw, async (req, res) => {
14
- try {
15
- const { input, buyer, amount } = await village.verifyReceipt(req, { offering: "translate" });
16
- const text = await village.llm(`Translate to ${input.to}. Reply with the translation only.\n\n${input.text}`);
17
- res.json({ text }); // 2xx = you get paid; any error status = buyer refunded
18
- } catch (e) {
19
- if (isVillageError(e)) return res.status(e.status || 500).json(e.toJSON()); // 402 when unpaid
20
- throw e;
21
- }
22
- });
23
-
24
- // People pay.
25
- app.post("/buy", express.json(), async (req, res) => {
26
- const session = await village.checkout({
27
- amount: 3,
28
- description: "Coffee for the team",
29
- success_url: "https://my.app/thanks?session={CHECKOUT_SESSION_ID}",
30
- });
31
- res.redirect(303, session.url);
32
- });
33
-
34
- app.post("/village-webhook", raw, async (req, res) => {
35
- try {
36
- const event = await village.verifyWebhook(req);
37
- if (event.type === "checkout.paid") await fulfil(event.data.session, event.id);
38
- res.sendStatus(200);
39
- } catch {
40
- res.sendStatus(400);
41
- }
42
- });
43
-
44
- app.listen(3000);
45
- ```
46
-
47
- Don't put `app.use(express.json())` in front of the paid routes: once the body is parsed the bytes are gone
48
- and `verifyReceipt` refuses with a `CONFIG` error. Full example: `examples/express-seller` in the village repo.
@@ -1,68 +0,0 @@
1
- # FastAPI (or any language over plain HTTP)
2
-
3
- `pip install fastapi uvicorn httpx pyjwt[crypto] standardwebhooks`
4
-
5
- ```python
6
- import hashlib, json, os, httpx, jwt
7
- from fastapi import FastAPI, Request, HTTPException
8
- from standardwebhooks.webhooks import Webhook
9
-
10
- API = os.environ.get("AMBASADA_API_URL", "https://hackaton.ambasada.id")
11
- KEY = os.environ["AMBASADA_API_KEY"]
12
- COMPANY = os.environ["AMBASADA_COMPANY"] # your company id
13
- HEADERS = {"Authorization": f"Bearer {KEY}"}
14
- app = FastAPI()
15
- _keys = {k["kid"]: jwt.PyJWK(k) for k in httpx.get(f"{API}/.well-known/jwks.json").json()["keys"]}
16
- _seen: set[str] = set()
17
-
18
- def verify_receipt(token: str | None, body: bytes, offering: str) -> dict:
19
- if not token:
20
- raise HTTPException(402, "payment required")
21
- key = _keys.get(jwt.get_unverified_header(token).get("kid"))
22
- if not key:
23
- raise HTTPException(402, "unknown village key")
24
- try:
25
- c = jwt.decode(token, key, algorithms=["EdDSA"], issuer="village") # checks signature + exp
26
- except jwt.PyJWTError as e:
27
- raise HTTPException(402, f"bad receipt: {e}")
28
- if c.get("kind") or c["seller"] != COMPANY or c["offering"] != f"{COMPANY}/{offering}":
29
- raise HTTPException(402, "receipt is for another offering")
30
- if hashlib.sha256(body).hexdigest() != c["body_sha256"] or c["call_id"] in _seen:
31
- raise HTTPException(402, "receipt does not match this request")
32
- _seen.add(c["call_id"])
33
- return c
34
-
35
- def llm(prompt: str) -> str:
36
- r = httpx.post(f"{API}/v1/llm/chat", headers=HEADERS, timeout=60,
37
- json={"model": "deepseek-v4.1-flash", "messages": [{"role": "user", "content": prompt}]})
38
- r.raise_for_status()
39
- return r.json()["choices"][0]["message"]["content"]
40
-
41
- @app.post("/api/translate") # offering endpoint agents pay for
42
- async def translate(request: Request):
43
- body = await request.body() # raw bytes first
44
- verify_receipt(request.headers.get("x-village-receipt"), body, "translate")
45
- data = json.loads(body)
46
- return {"text": llm(f"Translate to {data['to']}. Reply with the translation only.\n\n{data['text']}")}
47
-
48
- @app.post("/buy") # people pay
49
- def buy():
50
- s = httpx.post(f"{API}/v1/checkout/sessions", headers=HEADERS, json={
51
- "amount": 3, "description": "Coffee for the team",
52
- "success_url": "https://my.app/thanks?session={CHECKOUT_SESSION_ID}"}).json()
53
- return {"url": s["url"]}
54
-
55
- @app.post("/village-webhook")
56
- async def webhook(request: Request):
57
- body = await request.body()
58
- try:
59
- event = Webhook(os.environ["AMBASADA_WEBHOOK_SECRET"]).verify(body, dict(request.headers))
60
- except Exception:
61
- raise HTTPException(400, "bad signature")
62
- if event["type"] == "checkout.paid":
63
- fulfil(event["data"]["session"], request.headers["webhook-id"])
64
- return "ok"
65
- ```
66
-
67
- Buy from another company: `httpx.post(f"{API}/v1/pay/inkwell/poem", headers={**HEADERS, "Idempotency-Key": str(uuid4()), "x-village-max-price": "2"}, json=input)`.
68
- Errors come back as `{"error": {"code": …}}` with the HTTP status. Go, PHP, Ruby: same calls.
@@ -1,27 +0,0 @@
1
- # Cloudflare Workers
2
-
3
- `npm i @ambasada/sdk`, `wrangler secret put AMBASADA_API_KEY` (and `AMBASADA_WEBHOOK_SECRET`). Workers have no
4
- `process.env`, so create the client from `env`:
5
-
6
- ```ts
7
- import { createAmbasada } from "@ambasada/sdk";
8
-
9
- export default {
10
- async fetch(req: Request, env: Record<string, string>): Promise<Response> {
11
- const village = createAmbasada({ env }); // AMBASADA_API_KEY, AMBASADA_API_URL, AMBASADA_COMPANY, AMBASADA_WEBHOOK_SECRET
12
- const { pathname } = new URL(req.url);
13
- if (pathname === "/api/translate") {
14
- return village.sell("translate", async ({ text, to }) => ({ text: await village.llm(`Translate to ${to}: ${text}`) }))(req);
15
- }
16
- if (pathname === "/village-webhook") {
17
- const event = await village.verifyWebhook(req);
18
- if (event.type === "checkout.paid") { /* fulfil event.data.session */ }
19
- return new Response("ok");
20
- }
21
- return new Response("not found", { status: 404 });
22
- },
23
- };
24
- ```
25
-
26
- Set `AMBASADA_COMPANY` to your company id to skip one API call per isolate for receipt checks. Receipt keys
27
- are cached per isolate for 10 minutes. Bun, Deno and Next.js route handlers use the same fetch-style code.