Set up
Hosted by its author. Add the URL to your agent — nothing to install.
Settings → Connectors → Add custom connector
Name: padel-matchup
URL: https://kicksma.sh/mcpsame format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12
Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs
README
synced Oct 17, 2026Kicksmash — padel match-up in one link
Mobile-first web app for organizing padel matches with zero app installs, zero accounts, zero passwords. Create a match, share kicksma.sh/{code} on WhatsApp or Telegram, friends tap → enter a name once → they're in.
- Stack: Next.js 15 (App Router, TypeScript) · Supabase Postgres + Drizzle · Resend · next-intl (EN/RU/ES) · Tailwind v4 · Vercel (Cron + OG images).
- Open source under the Apache License 2.0. Run your own copy, build on it, send a PR: see CONTRIBUTING.md and SECURITY.md.
- Identity: one-time name entry → player UUID in a signed httpOnly cookie (1 year) + a restore seal in localStorage (the server signs it for that player and that purpose; a lost cookie comes back from the seal, never from a bare player id). Cross-device: every player has a private personal link (
/p/{token}, shown on My matches, in every email and in the calendar invite) that signs any device in; an email that was used before can restore history with a 6-digit code, merging all identities that share it. The home-screen shortcut opens the personal link, and calendar entries and emails carry the private event link (/p/{token}/{code}: signs the device in, opens the match). A newly added email receives the personal link (inside the calendar invite when in a match, otherwise on its own). Tokens are 12 characters; older 32-char tokens keep working asprevious_tokenafter the lazy shortening. An email can be changed but never blanked once set; the previous address is kept asrecovery_email, so a restore code sent to either address gets the player back in. "Email me this link" on My matches mails the personal link (native share, copy and QR are the other options). Passkeys (DECIDING rule 39): after a join, "Remember me on this phone" saves a passkey in the phone's password manager, and from then on "Sign in with Face ID or fingerprint" signs any browser on that phone in, first in "Played before?", which stands open under the name field of the match page's join form. A session by name never saves one; a sign-in folds only the bare new record of the same name that this browser made. Kicksmash keeps the public key, a label and two dates (passkeys), and My matches lists "Phones that remember you" with Remove. - Notices, quiet hours and an inbox: every notice to a player passes one gate (
src/lib/domain/notices.ts): it lands in their inbox on My matches first (ninety days, newest first, in the reader's language), and goes out on their channel only when that kind is on (seven kinds, all on but the club's new matches) and it is not their quiet hours, unless the match starts within three hours. What quiet hours held goes as one short message per channel when they end, from the hourly cron. - Push reminders: Web Push (VAPID) one hour before each match, for every device the player enabled it on (iPhone: from the home-screen app).
/api/cron/pushis called every 5 minutes by Supabasepg_cron+pg_net(Vercel Hobby cron is daily). SetVAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY,VAPID_SUBJECT. - Links:
/{code}(4 chars, public) ·/{code}/i/{6}(personal invite) ·/{code}/manage/{10}(organizer secret). - Agent-native and open. A public REST API (OpenAPI 3.1 at
/api/openapi.json), an MCP server at/mcpthat any assistant adds by URL, instant self-serve keys, signed webhooks,llms.txt,/.well-known/mcp.json, a robots.txt that welcomes AI crawlers,AGENTS.md, an installable skill (npx skills add evhg/padel-matchup), calendar feeds per group and venue. Public data is CC BY 4.0. See/developersand/agents. - Email is optional everywhere. Without
RESEND_API_KEYthe app runs fully with email features hidden. - Privacy, short and in full:
/aboutsays what is stored, what is never done, and how to leave, in a few lines;/privacy(the one faint footer link) and/termssay it in full, with the cookies, the processors and the figures the code enforces (src/lib/legal.ts). Every organizer-initiated email (invites, invite reminders) carries a signed one-tap/unsubscribelink; a player adding their own address again lifts the opt-out. "Delete my account" at the bottom of My matches wipes personal data (name, email, phone, personal link, the linked Telegram, Discord and LINE accounts, the public page, the passkeys), releases upcoming spots, cancels the player's own upcoming matches, and — if they coach — cancels their upcoming lessons (each student told, with the coach's real name) and takes their coach page down with everything on it; old scores stay as "Deleted player". A coach who wants only the page gone, keeping the account, has "Delete my coach page" at the bottom of their settings, which refuses while a lesson is still to come. - Levels: every player can declare a padel level (0–7 in quarter steps, the scale the padel apps use) once, from My matches or the first time a ranged match asks for it. It shows as a small chip next to the name everywhere (roster, standings, team picker). Results nudge it: when the organizer confirms a 2v2 score or finalizes a tournament, a small Elo-style delta (at most ±0.10 per match, ±0.12 per tournament) is applied once per event and logged ("3.25 → 3.30 after a match"). Players without a level neither move nor count. Organizers can set a level range per match or tournament: presets Bronze 1.0–2.5, Silver 2.5–3.5, Gold 3.0–4.5, Platinum 4.5+, or a custom min–max. Players inside join as usual; players outside ask to join and the organizer approves (seats them, or waitlists them when full) or declines, with the answer shown in the join bar and the activity feed. Reserved/invited players and the organizer bypass the range. The score panel suggests balanced teams (smallest level gap) when all four have levels. My matches gets a stats strip: played, won, win rate, podiums.
- Abuse limits (per UTC day unless noted, generous for humans, tight for scripts): 40 new identities per IP, 20 matches per player, 40 invitations per organizer, 30 joins per player per hour, 10 email changes, 5 personal-link mails, 20 restore codes per IP, 60 browser crash reports per IP. Counters live in
metrics_daily, no extra infrastructure. Hitting one returns "too many" and nothing else happens.
Quick start (zero config, ~2 min)
pnpm install
pnpm dev
That's it. With no DATABASE_URL the app boots an embedded PGlite database in ./.pglite, applies migrations and seeds two example events:
| URL | What you get |
|---|---|
| http://localhost:3000/PLAY | Upcoming match: 2 joined, 1 reserved invite, 1 open spot |
| http://localhost:3000/PAST | Finished match with an organizer-confirmed 3-set score |
| http://localhost:3000/new | Create your own |
Copy .env.example to .env to change anything. All flows (join, waitlist, invites, scores, "My matches", OG previews) work end-to-end without any keys.
pnpm test # vitest: slot-claim concurrency, invite transitions, score-lock rules, reminders, identity, americano rotation, rate limits, opt-outs, account deletion
pnpm typecheck
pnpm lint
pnpm build
pnpm e2e # the sixteen Playwright journeys against a fresh production build (`node scripts/suites.mjs --why` says which a change can break); first time: pnpm exec playwright install chromium
pnpm e2e boots next start on port 3001 with a throwaway PGlite database, a dummy Resend key (email UIs on, sends fail harmlessly) and generated VAPID keys, then runs every e2e/*.mjs suite. SHOTS=./shots keeps full-page screenshots; PW_CHROMIUM=/path/to/chromium uses a preinstalled browser. GitHub Actions runs typecheck, lint, vitest on PGlite and on a real Postgres service, the build, and the e2e suites on every push and pull request (.github/workflows/ci.yml).
Tests run on in-memory PGlite by default. To run them against a real Postgres (true concurrency), point TEST_DATABASE_URL at a disposable database — its public schema is dropped before each test file:
TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/padel_test pnpm test
Environment variables
Only one variable is required in production: the database URL. Everything else has a safe default.
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL | ✅ | Supabase Transaction pooler string (port 6543), exactly as Supabase's Connect dialog shows it. POSTGRES_URL (the Vercel ⇄ Supabase integration) and SUPABASE_DB_URL work too. Empty → the embedded PGlite database, for local development only. |
DATABASE_PASSWORD | if the URL still says [YOUR-PASSWORD] | Substituted into the URL and percent-encoded for you. |
DIRECT_DATABASE_URL | no | Direct (port 5432) URL for pnpm db:migrate and pnpm db:generate. POSTGRES_URL_NON_POOLING works too. |
AUTO_MIGRATE | no | false stops the app applying migrations on its first connection. That safety net is for a fresh database only: production gets each migration from the Migrate workflow (AGENTS.md rule 7). |
APP_BASE_URL | no | Defaults to the Vercel production domain. Set it locally and on other hosts. NEXT_PUBLIC_APP_BASE_URL is the browser's copy of the same value. |
SESSION_SECRET | recommended | Signs the identity cookie. Without it a stable secret is derived from the database URL. |
CRON_SECRET | recommended | Protects /api/cron/* and the one-off setup routes. Vercel sends it automatically when set. |
RESEND_API_KEY | no | Enables every email: calendar invitations, notifications, reminders. |
EMAIL_FROM | no | Defaults to Kicksmash <matches@<your domain>>; the domain must be verified in Resend. |
RESEND_WEBHOOK_SECRET | no | Verifies Resend's inbound webhook, so a reply to feedback@ or claude@ becomes a note. |
OUTREACH_FROM | no | The From line on outreach mail. Defaults to Claude at Kicksmash <claude@<your domain>>. |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT | no | Enables push reminders (npx web-push generate-vapid-keys). |
TELEGRAM_BOT_TOKEN / TELEGRAM_WEBHOOK_SECRET / TELEGRAM_BOT_USERNAME | no | Enables the Telegram bot and Telegram sign-in. Register the webhook once with GET /api/telegram/setup (Bearer CRON_SECRET). The username defaults to the one in src/lib/config.ts; the token is what decides whether there is a bot at all. |
TELEGRAM_MINIAPP_SLUG | no | The Mini App's short name, so cards can carry a direct link into it. Without it the bot's cards link to the web instead, which is right until the app exists. |
TELEGRAM_OWNER_ID | no | The owner's Telegram id: proposals, club claims and drafts go there for a one-tap answer, and the admin desks open for that account only. |
DISCORD_BOT_TOKEN / DISCORD_PUBLIC_KEY | no | Enables the Discord bot. Register the commands and the interactions URL once with GET /api/discord/setup (Bearer CRON_SECRET); it returns the install link. |
DISCORD_APPLICATION_ID | no | Read from the bot token unless set. |
DISCORD_INVITE_URL | no | Shows the server on the community pages. |
WHATSAPP_TOKEN / WHATSAPP_PHONE_ID / WHATSAPP_NUMBER / WHATSAPP_VERIFY_TOKEN / WHATSAPP_APP_SECRET | no | Enables the WhatsApp thread: one person at a time, not a card channel. Meta's Groups API makes only the business's own groups, joined by an invite link. A group holds eight people at most, needs an Official Business Account, and allows no edit of a message. So the bot can never sit in a chat the crew already has. A group that the bot makes waits for the owner's Meta steps (ROADMAP.md). Point Meta's webhook at /api/whatsapp/webhook: the GET answers the subscription handshake with WHATSAPP_VERIFY_TOKEN, and every POST is refused unless signed with WHATSAPP_APP_SECRET. WHATSAPP_NUMBER is the number people message, and builds the wa.me line an organiser pastes into their own group. |
WHATSAPP_TEMPLATES_PER_DAY | no | How many message templates WhatsApp may send in one UTC day: a match's changes, a free spot, the score ask and the result card, to players whose channel is WhatsApp. Defaults to 50; 0 switches them off and those notices go by email or push. Meta bills each template it delivers outside an open 24-hour window, so this is the cost guard (docs/OPERATING.md). |
WHATSAPP_WABA_ID | no | The WhatsApp Business Account id (not the phone number id). Only scripts/whatsapp-templates.mjs reads it, to list the templates Meta holds and to create the missing ones from data/whatsapp-templates.json. |
LINE_CHANNEL_TOKEN / LINE_CHANNEL_SECRET | no | Enables the LINE bot: the card in a Thai crew's group chat. Point LINE's webhook at /api/line/webhook; every delivery is refused unless signed with LINE_CHANNEL_SECRET. LINE cannot edit a sent message, so the card is re-sent only when something a player would notice changed, and a reply (free) is used wherever an answer is possible instead of a push (metered). |
PASSPORT_PRIVATE_KEY / PASSPORT_PUBLIC_KEY | no | Ed25519 pair (raw 32-byte hex each) that signs player passports. Without them a passport carries alg: "none". |
ANTHROPIC_API_KEY | no | Drafts the replies on the listening desk and the proposal the owner receives for a note. |
ANTHROPIC_WORKSPACE_ID | no | The workspace for a key that has none of its own: every Messages API call then sends it as the anthropic-workspace-id header. Find it in the Claude Console, under Settings, then Workspaces: an id that starts with wrkspc_. Leave it empty for a key made inside a workspace. A key with no workspace and no id here is refused, as a dead key is (docs/OPERATING.md). |
LISTEN_MODEL | no | The model those drafts use. Defaults to claude-sonnet-5, which src/lib/ops/anthropic.ts also prices. |
ANTHROPIC_MONTHLY_CAP_USD | no | The ceiling the app keeps itself under, in US dollars. Defaults to 20. |
ANTHROPIC_ADMIN_KEY | no | An Admin API key, so the service board reads the real spend instead of its own estimate. |
SCRAPE_DISABLED | no | Platform ids, comma separated (playtomic,matchi), or all: the fifteen-minute read of free court times stops for those platforms (DECIDING rule 35). Vercel applies it at the next deploy; the scrape_off_<platform> row through POST /api/admin/metrics needs none (docs/OPERATING.md). Empty reads every platform that has a reader and is not resting after a block. |
TAVILY_API_KEY | no | The research desk: clubs and coaches per city, and grounding for answer pages. |
REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET / REDDIT_USERNAME / REDDIT_PASSWORD | no | Lets an approved reply be posted on Reddit as the project's account. Without them, Approve means copy and paste. |
GOOGLE_SERVICE_ACCOUNT_JSON | no | A service account with Search Console access, so the service board can read impressions and clicks. |
INDEXNOW_KEY | no | Tells Bing and Yandex a page changed, the moment it changes. |
BACKUP_GITHUB_TOKEN / BACKUP_GITHUB_REPO | no | The nightly database snapshot is committed to that repository. |
UPTIME_REPO | no | owner/repo of the GitHub Actions uptime probe, so the service board can read its open incidents. |
OPERATOR_VERCEL_TEAM / OPERATOR_VERCEL_PROJECT | no | Names the Vercel project, so a missing key can be reported with the link that sets it. |
Generate secrets: openssl rand -base64 32. The table above and .env.example are written by node scripts/gen-docs.mjs from the process.env reads in the code, and tests/docs.test.ts fails when either falls behind, so a variable the code reads cannot go undocumented. Check a deployment any time at /api/health (no secrets returned).
Production setup
The walkthrough lives in docs/DEPLOY.md: Supabase, Vercel, a domain, Resend and the cron jobs, in a browser-only version and a CLI version. Nothing in it is needed to run the app locally.
npm packages
The pure engines ship as packages, generated from src/lib/domain so there is one source of truth:
@erikv69/americano:buildSchedule({ names | players, courts, rounds, seed })for a whole americano, plus the round-by-round planners (planRound,planMexicanoRound,planKingRound), histories and standings.@erikv69/levels: bands, presets, ranges andlevelFit,balancedTeams,matchDeltasandtournamentDeltas.
pnpm packages:build regenerates packages/*/src and packages/*/dist (both gitignored); tests/packages.test.ts builds them on every CI run. To release: bump the version in packages/<name>/package.json, build, npm publish from that directory.
Community
Questions, ideas and "I built a thing on the API" go to GitHub Discussions. Bugs go to issues. There is a Discord server too (the link is on /developers), where the bot answers questions about once an hour. The Telegram bot and the Reddit account answer people where they are; the code and the roadmap live here.
Deploy your own
Kicksmash is one Next.js project and one Postgres database, Apache-2.0. Run it for your club, your city or your country: docs/DEPLOY.md has the Vercel button, the Docker build and the walkthrough. The environment table above is the whole configuration, and a test keeps that true.
Product rules baked in
- Match = exactly 4 players. Tournament = creator-set capacity (4–64, in fours) running as an americano: round 1 starts with any four names or more (americano and mexicano rest whoever does not fit a court, in turn, so nobody rests twice before everyone has rested once; King of the Court still needs names in fours; reserved-but-unaccepted names count; they get a placeholder player that merges into the real one on accept) and closes any spots still open, so the tournament becomes exactly the players present; the organizer generates rounds on the day (rotating partners, fair sit-outs, 1–N courts), courts follow the players (one per four) and can be given real names, the schedule is exact when the field is in fours (every pair partners once in players−1 rounds; the next round replays round 1), the latest round can be deleted even if scored (confirmed), any participant enters per-court points as soon as a round exists (no need to wait for the start time), standings update live, the organizer finalizes to lock.
- Fast create: quick-pick chips only from the organizer's own history (their usual weekday/time slots from the matches people played, with two or more taken seats, or from the whole history until there is one; projected to the next occurrence; the default date is their most usual slot), the keyboard's Go key moves to the next field and never creates the match, venue optional ("Court TBD") with an optional court field (court appears in the page, share text, email and calendar titles), the landing page is the form, "Play again" appears once a result is in and clones the match on the day and at the time in the one control above it (a week on by default), and each player of that match gets one "I'm in?" message, once (DECIDING rule 37), and an Add-to-Home-Screen prompt gives organizers an app-like shortcut to their personal link.
- Paste your booking: a court already booked in a platform's app (Playtomic, MATCHi, The Padel Society and others) becomes a match from its pasted link or share text. On the first page, "Booked already? Paste it" opens the create form with the club, the day, the time, the length, the court, the price and the players filled, and marks what the booking left out or what to check. A price fills "Cost per player" only when the booking says it is per player; any other price is the court's total, named in one line and never asked of each player. In a private chat with the bot, the answer says what it read, and "Create match" makes the match in one tap when the words gave the club, the day and the time. Kicksmash reads a pasted link once, as a chat app's preview robot reads it (
src/lib/booking/unfurl.ts, DECIDING rule 35). It never signs in to a platform and never books or pays. - Who has paid: when a match names a cost, everyone in it sees the same list of names. A player taps "I have paid"; the organiser taps to say it arrived, and can take it back. Two fields, not one flag — "I paid" and "it arrived" are different claims by different people, and only the organiser may write the second. No money passes through Kicksmash and nothing is uploaded; the app remembers what the two of them said, so nobody has to chase it in the chat.
- When full: per event, waitlist with auto-promotion on dropout (leave / removal / declined invite) or hard close. When a seat is free and nobody is waiting for it (a dropout, or a spot nobody took), and the match is between 90 minutes and 48 hours away, one notice goes to the people who would take it: whoever asked for that hour and place, the group's other members, the players who played a finished match with somebody in it in the last 60 days, then the players with a match at that venue in the last 90 days or still to come, when the match is on the club's board. Each hears once on the channel they have: a private Telegram message with the card's one-tap "I'm in", else an email with the match link, else a push. Once per match ever, only to players the level range admits and a channel reaches, capped at 40, and never to the organizer or to whoever just left. A private match with no group reaches its players' past partners and nobody else.
- Join is first-come-first-serve and atomic: the event row is locked per mutation and one
UPDATE … WHERE id = (SELECT … LIMIT 1)claims exactly one slot, so two taps on the last spot resolve cleanly (tested with 12 parallel joins). - Reserved slots get a personal invite link. The organizer reserves by tapping an open spot in the roster, which expands in place (name, optional phone/email, Done); with an email the invite is sent immediately ("Invite emailed"); without one the row says "Invite not yet accepted" and offers "Invite now" / "Remove player". Anyone else tapping an open spot joins in place. A held name with no email or phone can also take its place from the plain match link: typing the name in the join form offers "Take Danny's held place", in two taps (DECIDING rule 34). A player already in the match cannot use somebody else's personal link: the page says whose place it is, and the server refuses the tap. When a reserved player's email is already known from another identity, the invite page offers "Played before with …? Restore with a code" before confirming; typing a known email or phone in the reserve form suggests that player's name. A known email is shown with an Edit button instead of being asked for again, plus an on/off switch for activity emails (join/leave/respond notices, line-up changes, the score reminder); calendar invites of a match you join always go out, and time changes and cancellations go out unless you switch match changes off under Notices on My matches.
- Activity feed is phrased from the viewer's side: "You added Zed" vs "Zed was added by Erik". No popups anywhere: overlays drift off screen on iOS once the keyboard opens.
- Stay updated, and a calendar that keeps itself. The moment a member with no channel joins, "Stay updated" asks where they want to hear about it: WhatsApp (once a number is configured), Telegram or email, never push. Telegram and WhatsApp are one tap into the app they already have (a signed link that ties that account or number to the web player); email sends a real invitation to a seated player (updates itself on changes and cancellation), and the personal link to one on the waiting list, whose invitation follows a place; the card says which went. Once one is linked the card is one quiet line ("Updates reach you on Telegram ✓") and never asks again. A WhatsApp player gets the match's changes, a free spot (whose "I'm in" joins), the score ask and the result card as four utility templates Meta approves (
data/whatsapp-templates.json), at mostWHATSAPP_TEMPLATES_PER_DAYa day; a template that cannot go falls through to email, then push. No Google/Apple buttons for a single match: those create unlinked copies we could never update. A chat player's calendar is instead a feed of their own matches (/p/{key}/calendar.ics, from thirty days back, the same UID and fields as the invitation, a cancelled match marked), offered aswebcal://and through Google Calendar on a computer; the key is a hash of the personal token, so the address signs nobody in. Invitation entries carry exactly one link, the short private event link (kicksma.sh/p/{12 chars}/{code}), plus the player list once complete; feed entries carry the public match link. - Line-up complete: when every spot is joined/confirmed the calendar entry is re-sent with
- COMPLETEin the title and the player names in the description (and reverted if someone drops out). The app never messages anyone; organizers get one-tap WhatsApp / Telegram forward buttons with pre-filled localized text. Declined slots become open spots. - Rolodex: everyone who ever joined or was invited to your events, with their email/phone reused automatically.
- Venue memory: last-used venue pre-filled, all previous venues in the combobox, free text adds a new one.
- Timezone: default from Vercel's
x-vercel-ip-timezone(browser zone as fallback), editable, stored UTC. - Score: any participant after start; players can correct each other; once the organizer enters/edits it locks ("Confirmed by organizer"). 1–3 sets, optional team assignment → win/loss in My matches.
- Organizer access = creator cookie or the 10-char manage link (sets a per-event httpOnly cookie).
- Telegram bot (@kicksmash_bot), quiet by design: add it to a padel group chat and it keeps one card per match there: title, time, venue, level range, the roster with levels, spots left, and two buttons, "I'm in" and "Can't make it". Taps join or leave through the same code path as the web button (organizer note, calendar invite, waitlist promotion, webhooks); the card is edited in place, never re-posted. The bot posts a new message only four times: the card itself (
/newhands out a create link bound to the chat; a pasted kicksma.sh link becomes a card when the bot is admin or privacy mode is off;/match CODEworks always), one short "line-up complete" note, one reminder about an hour before (via the 5-minute push cron), and the result picture once the organizer confirms. English or Russian per chat (/lang). People who tap become players with just their first name; Telegram sign-in on My matches (Login Widget) links or merges that account with the browser identity. A reply "+1" or "can't make it" to a card does what the two buttons do, and the bot answers with a reaction, not a message. Privacy mode stays on: the bot reads plain messages only in a crew's own group, one that a group admin tied to a crew with the link the crew page shows the crew's admins ("Run a Telegram group for this crew"), where the bot is an admin and its notice is pinned; there "in", "out" and "who's in Thursday 7pm Rawai?" (at a court the chat already knows) work without a tap, in English, Russian and Spanish, and/quietfrom a group admin stops it acting on words. It keeps first names, Telegram usernames, who is in or out, the court and the score, never the text. Module:src/lib/telegram/. - Discord bot, the same manners: HTTP interactions only (no gateway process), so it runs on the same serverless functions.
/newhands out a create link bound to the channel,/match CODEposts the card, the two buttons join and leave through the shared operations and the card updates in place for everyone; one "line-up complete" note, one reminder, the result once./askanswers a padel or Kicksmash question on the spot, and the hourly tick reads new messages in the servers the bot is in, keeps the ones that read like questions and answers them in a reply (this is our own community, so no tap is needed; the shared daily budget still applies, and every reply grows an answer page)./lang en|ruper channel. Module:src/lib/discord/. - Listening desk (helpful replies, never on their own): every hour the app reads public feeds where people ask about organising padel (Hacker News via Algolia, r/padel and Reddit searches via RSS), keeps the last week of items, gates them cheaply (padel + an organising intent), and asks the model for a reply in the thread's language under strict tone rules: answer first, no hype, mention kicksma.sh at most once and only when it solves the question, disclose that we build it. Drafts go to the owner on Telegram with Approve / Skip / Edit buttons, at most six a day; Approve posts on Reddit as the project's account (or, without Reddit keys, marks it for a manual copy).
/admin/listenis the desk (owner only, via Telegram sign-in). Daily ceilings on drafts and tokens keep a capped API key safe. Approved replies grow into evergreen answer pages at/answers/{slug}(question rewritten generically, QAPage JSON-LD, in the sitemap), published at once; a Sunday digest on Telegram lists the week and offers one-tap Unpublish for each new page. Module:src/lib/listen/. - Feedback, and who decides:
/feedbackin Telegram or Discord, the web form at/feedback, or an email tofeedback@becomes a note. The person gets an honest thank-you at once (the note was read; they hear if something gets built; nothing else is promised). The owner gets a proposal on Telegram the moment a real note arrives: the verdict the rules indocs/DECIDING.mdgive, what would change and where, the size (small, medium, large), a timeline estimate made without reading the code, what it needs, and a recommendation, ending with "build" or "skip". Nothing is built from a note until the owner says so, and the person hears only what shipped. A production error the store has never seen is one line to the owner too, at first sight, a few an hour at most. The note and the error are the triggers; there is no daily loop. - Embeds and oEmbed:
/embed/board/{slug}and/embed/match/{code}are iframe-safe views (no header, opens on kicksma.sh in a new tab, "Live from kicksma.sh" footer); the venue board shows the snippet under "Embed this board"./api/oembed?url=…&format=jsonis an oEmbed provider and match and board pages advertise it with<link rel="alternate" type="application/json+oembed">, so WordPress, Discourse, Ghost and Notion unfurl a pasted link into the live card. Helper:src/lib/embed.ts. - Deploy your own: the app is one Next.js project with a Postgres database.
Dockerfilebuilds a standalone image; the README's environment table is the whole configuration, and a test keeps that true. Everything is Apache-2.0; run it for your club, your city or your country. - Tournament formats: besides the americano rotation a tournament can run as a mexicano (round 1 random, then the courts follow the standings, 1st+4th against 2nd+3rd on each court; the next round waits for all scores) or as King of the Court (winners move up a court, losers move down, the top court's winners and the bottom court's losers stay, partners split every round, standings follow the court you finish on). Chosen with one chip when creating a tournament, changeable until round 1. Engine:
src/lib/domain/formats.ts. - Organizer-verified levels: after a finalized result the organizer sees a folded "Confirm levels" row and confirms, one tap each or all at once, the levels of the people they played with. A confirmed level shows a ✓ next to the chip and stays confirmed while it moves less than half a step.
- Rankings (opt-in, off by default):
/v/{slug}/rankingranks a club's finalized results from the last 90 days (3 points per win, 1 per draw, 3/2/1 for tournament podiums);/phuketand/singaporedo the same across a city's clubs and list the open matches there. Only players who switched on "Show me in rankings" (My matches, or one tap on a ranking page) appear. Cities:src/lib/domain/cities.ts. - Levels (0–7): players declare their level once in quarter steps (a plain select grouped by band, with a short guide); it shows next to their name in rosters, standings and the score panel. Results nudge it: when the organizer confirms a 2v2 match score or finalizes a tournament, an Elo-style delta (one level of difference ≈ 10:1 odds, at most ±0.10 per match, ±0.12 per tournament) is applied once per event to every rated player, the source becomes "adjusted by results" and the last change is shown on My matches with a capped log. Unrated players never move and never count.
- Level ranges: a match or tournament can be Bronze (1.0–2.5), Silver (2.5–3.5), Gold (3.0–4.5), Platinum (4.5+) or a custom min–max; the range shows as a chip in the hero. Inside the range people join as usual (the join form asks for a level once); outside it the button turns into Ask to join, the organizer sees the requests with the level and approves (seats or waitlists) or declines, the requester sees the answer in the join bar and gets a calendar invite or a short note by email. Organizer-reserved invites and promotions bypass the range, and so does a player who already sat in a finished, scored match with the organiser (the owner, 11 October 2026). Balanced teams: when all four have levels, the score panel suggests the 2v2 split with the smallest gap.
- Stats strip on My matches: played, won, win rate, podiums (from confirmed team results and finalized standings).
- Club pages and founding clubs: any venue board can be claimed by the club that runs it at
/clubs/claim(name, the city and country anywhere in the world, booking page, website, courts, a few lines about the club). The claim is self-serve and carries its own check: the claimant says their role at the club and gives a work contact; a work email at the club's own domain is confirmed by a 6-digit code on the spot, anything else the owner checks by hand; then one Telegram tap makes the page live, and the claimant hears the answer on Telegram or by email. Nothing of a pending claim shows to anybody but its claimant. Once live,/v/{slug}shows "Book on Playtomic / MATCHi / Playbypoint …" (the platform is recognised from the link, also on every match at that club), the website, the about text, the badges, and free courts today when the club shares a feed it already has: a calendar of bookings (.ics, free = courts − overlapping bookings inside opening hours) or a JSON list of free slots, refreshed hourly. Where a club shares no feed and its booking link (or, with none, its website) is on a platform Kicksmash has a reader for (Playtomic, MATCHi and Book & Go), Kicksmash reads the free court times from the platform's public club page, three days ahead. It reads today every 15 minutes for every club a reader can read, and the next two days about once an hour. The page and the API name the platform (DECIDING rule 35: no sign-in, no booking, no payment; the player books and pays on the platform;/aboutsays what KicksmashBot reads). A feed the club shared always wins. A free court two to six hours ahead goes once to the players who asked to play at that club on that day and around that hour, on the channel they have, with one button to the match form at that club and hour (one court a day for each want). A refused claim on a club the directory listed puts the listing back as the directory has it, under a new manage link, unless the refusal says it is not a club or a duplicate. The first ten clubs per city are founding clubs: everything stays free for them for good./clubslists live clubs by city;GET /api/v1/clubs?city=phuketand/api/v1/clubs/{slug}expose the same publicly; the MCP tool isfind_clubs. Clubs manage their page through a private link that also shows on My matches. Modules:src/lib/domain/clubs.ts,src/lib/booking/(platform detection, the clubs' feeds, the 15-minute read inscrape.tsand one reader per platform inadapters/). - Player passport (opt-in, off by default): under My matches a player can switch on a public page at
/u/{slug}(first name, level with band and the organizer-confirmed tick, played / won / win rate / podiums, the clubs they play at) and gets a signed level document at/u/{slug}/passport.json: Ed25519 over canonical JSON, public key at/.well-known/kicksmash-passport.json,verifyPassportin@erikv69/levels, 90-day expiry. No list of profiles exists. Download my data (/api/me/export) is one JSON file with everything we hold, tokens, manage links and a passkey's credential id and public key excluded (its phones show by label and dates). Level import: the level picker folds a "Have a level in another app?" row that maps Playtomic (same 0–7), 1–10 club scales and five-category systems onto 0–7 in the open; the table is on/levels. Modules:src/lib/domain/passport.ts(pure, WebCrypto),src/lib/domain/profile.ts. - Result cards: once a match has a score or a tournament has a scored round, "Share result" opens
/{code}/card: a page whose link unfurls with a generated picture (score boxes with the winning side highlighted, or the top five of the table) in WhatsApp and Telegram, the picture itself to long-press and save, share buttons, and "Organize your own match" as the way in. - Groups: "Turn this crew into a group" on any match with two or more players makes a group (
/g/{6 chars}) with the match's settings as defaults (venue, format, capacity, level range, time zone) and its players as members; the person who taps is the admin. Anyone with the link joins (name only); joining a group's match or accepting its invite makes you a member too. On the group page's member list, inGET /api/v1/groups/{code}and in the MCP toolget_group, a visitor sees the members' first names and the count, never their levels (canSeeMemberLevelsinsrc/lib/domain/groupAccess.ts); a match's own roster still shows the levels of its seated players, as every match page does. An admin can switch on Ask to join (the owner's decision E, 9 October 2026): a newcomer asks with an optional note, the admins hear it by Telegram, email or push, and see it on the group page, where they approve or decline; the person hears the answer, and a declined person may ask again after seven days. In such a group a seat in one of its matches is only a seat, never a membership (joinGrouptakesvia:self,matchoradmin). Any member creates the next match from the group page: the create form comes prefilled (including the next weekly slot), the match is linked back, and every other member gets an email and a push with their private link. Admins can set a weekly slot: the hourly cron creates the next match a few days ahead (lead time 1–14 days, default 5), seats the group's creator, notifies everyone, and never creates the same occurrence twice. Under the group's matches, once it has two scored matches in the last 90 days, a small season table: each member who played, by first name, with matches played, won and wins in a row (🔥 from three), most wins first. My matches lists your groups with the next match of each. - Venue boards (opt-in, off by default): an organizer can tick "Show on the venue board" when creating or editing a match with a venue. Listed, upcoming, not-cancelled matches appear on
/v/{venue-slug}(a public page named after the venue, with spots left and level range), and the match shows an "On the … board" chip./v/{slug}/posteris a one-page printable poster with a QR code to the board ("Scan for open padel matches at …"). The board's empty state and footer lead to the create form with the venue prefilled and the listing switched on. Slugs are ASCII-only and kept in sync when a venue is renamed; removing the venue unlists the match. - Americano generator:
/americanois a public, indexable page running the same rotation engine in the browser: players (or pasted names), courts, rounds; exact rotation when the field is in fours, fair sit-outs otherwise; print stylesheet; "Run it live on Kicksmash" carries the schedule into a real tournament: the names travel with it (/?type=tournament&capacity=N&s=gen&names=…), the create form opens under "Your americano, live" and names the players it carried, and each one takes a reserved spot with an invite link the moment the tournament exists (seatNames). A pasted list is capped at 64 names, the field the engine stops at. Both generator pages carry the feedback door.sitemap.xmllists/,/play,/americano,/about,/privacyand/termsin every language, beside the city, club, coach, series and answer pages, androbots.txtlets crawlers read them; personal, manage, invite, share and card pages stay out of the index.
API, MCP server and webhooks
Everything public on the site is available to programs and assistants, and everything the create form does is one call away.
| Surface | Where |
|---|---|
| REST reads (no key) | GET /api/v1/matches/{code}, /api/v1/boards/{slug}, /api/v1/groups/{code}, /api/v1/schedule?players=8&courts=2 |
| REST writes (key optional, rate-limited per address without one) | POST /api/v1/matches, POST /api/v1/matches/{code}/join |
| Keys | POST /api/v1/keys → instant, shown once; Authorization: Bearer ks_live_… |
| Webhooks (key required) | POST /api/v1/webhooks with url, events, optional filter; signed X-Kicksmash-Signature: t=…,v1=…; retried with backoff by the hourly cron |
| MCP | POST /mcp (streamable HTTP, stateless JSON). Matches and groups: about_kicksmash, get_match, find_matches, get_group, generate_schedule, create_match, join_match, create_api_key. Clubs and series: find_clubs, find_series. Coaching: find_coaches, coach_slots, request_coach, book_lesson, cancel_lesson. Plus resources with the model reference and the OpenAPI document. tests/docs.test.ts fails when this row falls behind src/lib/api/mcp.ts. |
| Discovery | /llms.txt, /llms-full.txt, /.well-known/mcp.json, /api/openapi.json, /developers, /agents, robots.txt explicitly allows AI crawlers |
| Feeds | /g/{code}/calendar.ics, /v/{slug}/calendar.ics; a player's own, private /p/{key}/calendar.ics |
Public shapes (src/lib/api/serialize.ts) carry first names and levels only; emails, phones, personal tokens and manage links never appear. Errors are { error: { code, message, hint, status } }; every hint says what to do next. Limits live in src/lib/domain/ratelimit.ts.
Registries and marketplaces
server.json at the repository root is the manifest for the official MCP registry (namespace io.github.evhg); publishing is one command for the repository owner: mcp-publisher login github && mcp-publisher publish. The skill in skills/kicksmash/SKILL.md is picked up by the skills index from the public repository. Smithery, Glama and mcp.so accept the remote URL https://kicksma.sh/mcp through their web forms.
Admin dashboard
/admin is a public, read-only usage page: hero figure with a health row (database, email, push, both crons, errors today), stat tiles with sparklines, meters against the free-tier ceilings (Supabase 500 MB, Resend 3,000/month and 100/day), and 7/30/90-day trend charts (growth, joins, outbound messages, errors, database size, totals). Counters live in metrics_daily (bumped by the app, snapshotted by the hourly cron); no personal data is shown. Errors are counted, never described: server actions that throw, cron steps that fail and browser crash screens (/api/client-error, rate-limited) each bump a counter, so the health row is the only alerting there is. Vercel bandwidth has no public API on Hobby, so it links to the dashboard.
Project map
src/app/ routes: / · /[code] · /[code]/share · /[code]/card (+ opengraph-image) · /[code]/i/[invite] · /[code]/manage/[manage] · /g/[code] (+ calendar.ics) · /v/[slug] (+ /poster, /ranking, calendar.ics) · /phuket · /singapore · /clubs (+ /claim) · /v/[slug]/manage/[token] · /api/v1/clubs (+ /[slug]) · /u/[slug] (+ passport.json) · /api/me/export · /.well-known/kicksmash-passport.json · /api/telegram/{webhook,setup,login} · /api/discord/{interactions,setup} · /admin/listen · /answers (+ /[slug]) · /embed/{board,match}/… · /api/oembed · /me · /p/[token] (+ /calendar, calendar.ics) · /about · /privacy · /terms · /play · /americano · /developers · /agents · /mcp · /api/v1/* · /api/openapi.json · /llms.txt · /.well-known/mcp.json · /unsubscribe · /admin · /api/cron/{hourly,push} · /api/client-error · robots.txt · sitemap.xml
src/app/[code]/opengraph-image.tsx link preview (Inter w/ Cyrillic, organizer's language)
src/actions/ server actions (identity incl. restore codes, events, slots, scores)
src/lib/domain/ pure business logic, driver-agnostic (events, slots, scores, reminders, queries)
src/lib/notify.ts every outbound email; safe no-op without RESEND_API_KEY
src/lib/calendar.ts Google Calendar URL + RFC 5545 .ics builder (also served at /{code}/calendar.ics); inviteFields, what every entry says
src/lib/calendarFeed.ts a player's own feed: its key, its matches, its .ics
src/lib/domain/identity.ts personal tokens, email one-time codes, identity merge
src/lib/domain/{ratelimit,optouts,anonymize}.ts abuse ceilings, unsubscribe list, account deletion
src/lib/domain/{levels,requests,rating}.ts level maths (ranges, presets, balanced teams, deltas), join requests, result-based adjustment
src/lib/domain/groups.ts groups, membership (joinGroup with via), asks to join, weekly slots (recurrenceDue / autoCreateGroupMatches)
src/lib/domain/groupAccess.ts pure: who sees members' levels, what a join does, when a declined person may ask again
src/lib/domain/venueBoard.ts venue slugs, the public board query, listing toggle
src/lib/api/ public REST (operations, serialize, keys, webhooks, openapi), MCP server (mcp.ts), model-facing docs (docs.ts)
src/lib/channels/ one card algorithm (cards.ts) with Telegram and Discord as adapters over it
src/lib/domain/facts.ts the append-only fact log every view is a query over
skills/kicksmash/SKILL.md installable skill for coding agents; AGENTS.md at the root for agents working on this repo
src/lib/domain/{levels,rating,requests}.ts level scale, presets, fit, balanced teams, Elo-style deltas; join requests
src/lib/alerts.ts error counters for the admin health row
src/db/schema/ the tables, one file per domain (enums, players, events, demand, groups, clubs, coaching, channels, api, ops)
src/db/ driver factory (postgres-js | PGlite), seed
drizzle/ generated SQL migrations
messages/{en,ru,es}.json all UI, share and email copy (identical key sets, typed in global.d.ts)
tests/ vitest against PGlite (or TEST_DATABASE_URL)
e2e/ Playwright journeys + runner (pnpm e2e)
.github/workflows/ci.yml typecheck · lint · schema vs migrations · vitest (PGlite + Postgres) · build · e2e (2 shards)
scripts/ gate.sh (the pre-push gate), check-migrations.sh, gen-docs.mjs (this README's env table)
Schema changes: edit the file for that domain under src/db/schema/ → pnpm db:generate → commit drizzle/ → the
Migrate workflow applies it when it reaches main (AGENTS.md rule 7). bash scripts/check-migrations.sh fails when the two disagree.