Skip to content

Pitch me: let card owners screen visitors before sharing their booking link - #159

Open
0xSY3 wants to merge 1 commit into
feat/landing-honest-copyfrom
feat/pitch-me-gate
Open

0xSY3 wants to merge 1 commit into
feat/landing-honest-copyfrom
feat/pitch-me-gate

Conversation

@0xSY3

@0xSY3 0xSY3 commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Stacked on #153 (feat/landing-honest-copy), which last edited ProfileChatWidget.tsx. Retarget to dev once #153 merges.

What

Team-doc idea 11 ("Gatekeeper"). An owner with a Calendly / Google Calendar link can turn on Ask visitors to pitch before booking (Edit, Profile tab, Booking card). Then:

  • The card's booking CTA reads Pitch to book a call. It opens the chat widget on a pitch step (1-3 sentences, 500 chars max).
  • The card's existing chat model (same Workers AI model and key as api/chat.py) checks the pitch against what the owner says they want: connect_with, can_help_with, working_on, love_talking_about, headline and the current ask.
  • Pass: the booking link(s) appear with a one-line reason, and stay unlocked for the rest of the visit.
  • Decline: a kind one-line reason, then "Request an intro" (when intros are on) or "Ask a question instead".

Owners who don't opt in see no change.

Server-side enforcement (the gate isn't cosmetic)

Before this change the booking URL was in the page HTML, data.json, /view, /v1/agents/{id}, the llms surfaces and the chat system prompt. For opted-in cards, services/booking_gate.public_booking now strips both URLs from every public read:

  • _public_card_payload (by-handle, list, by-alias), GET /cards?q=, GET /cards/{id}
  • build_card_view (/view, the agents API, data.json, llms, the memory MCP's /view reads)
  • the chat prompt (which also tells the model to point people to the pitch step and never guess a link)

The only way to get the URLs is POST /v1/chat/{handle}/pitch returning pass. Owner reads (/cards/mine, so the editor) still get the links. A public identity.pitch_to_book: true always means "a link exists and is withheld". It is cleared on public reads when there is no link to protect.

Storage: no migration

The flag lives in identity.pitch_to_book, the same way identity.edition does, because cards.s10_save_section (migration 0011) only accepts a fixed list of top-level keys. It is serialized only when true, so existing cards keep byte-identical JSON and turning it off doesn't trigger 409 identity drift. assemble_card resets it so scraped or LLM text can't turn it on.

Abuse / prompt injection

  • One verdict per visitor (forwarded IP) per card per 10 min (PITCH_WINDOW_SECONDS, PITCH_MAX_PER_VISITOR). There is also a per-card cap of 30/hour (PITCH_MAX_PER_CARD), because the IP key can be varied. Invalid-length pitches don't use up the check. Upstream transport failures give the visitor's check back.
  • Pitches that address the screener ("ignore previous…", "verdict", "approve me", …) are declined without a model call.
  • The pitch is wrapped in <pitch> tags, and visitor-supplied pitch tags are stripped so the text can't break out of them. The prompt marks the pitch as untrusted.
  • The verdict is accepted only as one JSON object {verdict: "pass"|"decline", reason} (pydantic, extra="forbid"). Prose, unknown enum values, extra fields, two objects or empty output give a 502 with no link (fail closed). URLs are stripped from the reason.
  • The screening prompt never contains the booking URL, so no injection can make the model reveal it. Only the server attaches it, on a pass.
  • Pitch text isn't stored or logged (the UI says so).

Tests

  • services/cards-api: pytest -q gives 1180 passed, 2 failed. The 2 failures are the known test_x_bot.py baseline. The new tests/test_pitch_gate.py (31 tests, mocked model) covers pass, decline, phrase-check injection, model-side injection that can't change the format, the rate limit, the per-card cap, the upstream-failure refund, an opted-out owner (409, links still public), unclaimed cards, booking URL never leaking (by-handle payload, /view, chat prompt, screening prompt), the identity-section save path, and both Workers AI response shapes. A mutation check (disabling public_booking) fails the leak tests.
  • apps/cards-web: tsc --noEmit is clean. eslint shows 0 errors (the 10 warnings in page.tsx were already there). next build --webpack exits 0.
  • Playwright tests/pitch-to-book.spec.ts (5/5): no booking link before a pitch, pass reveals it (and it stays for the visit), decline shows a reason and another route, a rate-limit message keeps the text, opted-out cards are unchanged. ask-card-cta.spec.ts (3/3) and edit-tabs.spec.ts (7/7) also pass. I ran these on a free port (3047) with the fixture API on 3048, because 3030 was taken by another run. startFixtureServer now takes an optional port for that.

For a human to notice

  • New key in the stored card JSON (identity.pitch_to_book). There is no SQL change, but it touches the shared card shape.
  • The visitor limit is keyed on x-forwarded-for like the existing chat limiter. The per-card cap is what bounds cost if that header is spoofed.
  • Not checked: anything outside this repo that reads card JSON straight from the database (for example the dashboard repo on xmfj). This PR gates cards-api reads only.

🤖 Generated with Claude Code

Owners with a Calendly or Google Calendar link get a flood of vague "let's
connect" bookings. This adds an opt-in "Ask visitors to pitch before
booking" toggle to the editor's Booking card. With it on, a visitor taps
"Pitch to book a call", writes 1-3 sentences, and the card's existing chat
model checks it against what the owner says they want (connect_with,
can_help_with, working_on, the current ask). A pass shows the link with a
one-line reason; a decline explains kindly and points to an intro or chat.

The gate is server-side, because hiding the button alone would be
cosmetic: the link was in the page HTML, data.json, /view, the agents
API, llms surfaces and the chat prompt. For opted-in cards every public
read now goes through booking_gate.public_booking, which drops both
booking URLs; only POST /v1/chat/{handle}/pitch returns them, on a pass.
Owner reads (/cards/mine) are unchanged, and owners who don't opt in see
no change at all.

No migration: the flag lives in identity (like the edition), because
cards.s10_save_section only accepts its fixed list of top-level keys. It
is stored only when on, so existing cards keep byte-identical JSON and an
off toggle doesn't read as identity drift.

Abuse and injection: same Workers AI model and key as card chat, one
verdict per visitor per card per 10 minutes plus a per-card hourly cap
(named constants), a 500-char limit, obvious "approve me" pitches
declined without a model call, and the verdict accepted only as a strict
JSON enum. Prose, extra fields or two answers fail closed with no link,
and the screening prompt never contains the booking URL at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant