Repository navigation
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #153 (
feat/landing-honest-copy), which last editedProfileChatWidget.tsx. Retarget todevonce #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:
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.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_bookingnow 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/viewreads)The only way to get the URLs is
POST /v1/chat/{handle}/pitchreturningpass. Owner reads (/cards/mine, so the editor) still get the links. A publicidentity.pitch_to_book: truealways 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 wayidentity.editiondoes, becausecards.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_cardresets it so scraped or LLM text can't turn it on.Abuse / prompt injection
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.<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.{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.Tests
services/cards-api:pytest -qgives 1180 passed, 2 failed. The 2 failures are the knowntest_x_bot.pybaseline. The newtests/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 (disablingpublic_booking) fails the leak tests.apps/cards-web:tsc --noEmitis clean. eslint shows 0 errors (the 10 warnings inpage.tsxwere already there).next build --webpackexits 0.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) andedit-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.startFixtureServernow takes an optional port for that.For a human to notice
identity.pitch_to_book). There is no SQL change, but it touches the shared card shape.x-forwarded-forlike the existing chat limiter. The per-card cap is what bounds cost if that header is spoofed.🤖 Generated with Claude Code