The conversion lane — designed backwards from the sale, so every question asked is one the invoice requires

Funnel

The engine's conversion lane. Say it in prose — "which gate is that school stuck at", "follow up", "segment this lead", "قمع" — and the funnel agent, the /funnel skill and the two guard hooks come with it.

The measured truth this lane exists to change: nothing here has ever converted. 0 active paying schools against a Q3 2026 target of 1. But since 2026-08-22 the lane is instrumented rather than remembered: the gates census (pnpm crm:funnel-gates in hogwarts) measured 377 reachable, unmessaged schools — 222 verified-mobile + 155 email (5× the remembered 45 mobiles; E.164 + Arabic-Indic normalization found what raw strings hid), the chatbot captures, and the outreach workflow is ACTIVE with its cards landing in the private #hogwarts-funnel Slack channel. Every conversion rate still starts at zero, and saying so is the point — the sibling scrape lane learned the hard way that a number remembered rather than measured drifts optimistic.

hogwarts — designed backwards from the invoice

The ladder was drawn from the invoice down, not from the visitor up. (mkan's is a different shape and gets its own section — it is free, so there is no invoice to work back from.) Start at the end:

PAID          subscription active + real usage in the last 30 days
   ↑            needs: a pilot that proved itself
PILOT         the committed free 3-month engagement
   ↑            needs: they have actually used it
TRIAL         a self-serve sandbox carrying the school's own name
   ↑            needs: a reason to try it
DEMO          a proposal or consult, priced from size × rail × term
   ↑            needs: we gave something and asked for nothing
DISCOVERY     authority, size, rail and timing all known
   ↑            needs: answers
WARM          they replied — one email or phone captured
   ↑            needs: a reason to answer
CONTACTED     the opening message went out (QUEUED until a human marks SENT)
   ↑            needs: the message actually sent
SHORTLISTED   picked for today's ramped batch — the workflow fires here
   ↑            needs: a verified contact and a slot in the ramp
PROSPECT      tiered and worth working, but silent
   ↑
COLD          /scrape's territory, not ours

Follow-up is not a final step. It is the edge on every arrow: each gate carries a stall timer, a next-best asset and a channel ladder, so a lead either advances, receives something new, or is retired to DORMANT on purpose.

What the client must actually give us

Everything upstream exists only to obtain seven facts, because you cannot issue a hogwarts invoice without all of them:

FactWhy the sale needs it
AuthorityOnly a signer can sign
Student countSets the price — $1.50 × N, floor $30/mo
CountrySets currency and rail: SD → Bankak/Cashi, Gulf/EG → card or wire
Curriculum + regulatorSudanese certificate, IGCSE, ADEK eSIS — decides fit
Current systemPaper, Excel or a competitor — the switching cost
Term startSchools buy before a term, never during one
A reachable channel119 emails against 45 mobiles of 176 contactables

Every question the bot asks is on this list, and every fact on this list gets asked. That is what "nothing random" means in practice.

No fourth vocabulary

hogwarts' gate is the union of the enums that already exist. Twenty's stage options are appended, never replacedCOLD, PROSPECT, PILOT and LOST already sit on 3,156 live rows, and Twenty stores an option's value on the record, so a swap would orphan them.

GateProspect.statusLead.statusTwenty stagelive rows (08-22)
COLDnew enriched queued contactedCOLD3,795
PROSPECTqueuedPROSPECT21
SHORTLISTEDqueuedSHORTLISTED (08-19)0
CONTACTEDcontactedCONTACTED (08-19)0
WARMrepliedNEWWARM0
DISCOVERYpromotedQUALIFIEDDISCOVERY0
DEMOpromotedPROPOSALDEMO0
TRIALpromotedNEGOTIATIONTRIAL0
PILOTpromotedNEGOTIATIONPILOT1
PAIDpromotedCLOSED_WONPAID0
DORMANTdead + dormant:<gate>ARCHIVEDDORMANT (08-18)
LOSTdeadCLOSED_LOSTLOST77

Measured against the live workspace on 2026-08-18, and it improved the design. company.stage already carried nine options with five of them holding zero rows — a ladder someone built and never populated. So the funnel adopts it and adds only what was genuinely missing: DORMANT (08-18), then SHORTLISTED + CONTACTED (08-19) for the outreach roll — 12 options live. No parallel REPLIED/QUALIFIED/CUSTOMER set: WARM is replied, DISCOVERY is qualified, PAID is customer. Ownership of the two new pre-reply stages is split on purpose: the funnel-gates.json artifact carries all 12, the funnel lane reports WARM and beyond, and SHORTLISTED/CONTACTED are the outreach report's numbers — contacted-but-silent is the stall that matters first.

The existing split of TRIAL (a self-serve sandbox) from PILOT (the committed free 3-month engagement) turned out to be better than the merged stage that had been designed on paper — the go-to-market needs both. COLD and PROSPECT belong to scrape; this lane starts at WARM, which is to say at a reply.

mkan — the same spine, a different job

mkan is free to hosts. Its funnel is therefore not a sale: nobody is being moved toward a payment, and there is no price path. The job is host success, and it splits in two — a ladder that ends, and a loop that does not.

The activation ladder — one-time, ends at LIVE

GateWhat happensWhat we give
SOURCEDWe scraped the unit and pre-built a draft listing. The host does not know yet.
INVITEDFirst touch on WhatsApp — Sudan is WhatsApp-first — carrying the listing preview and a single-use claim link.The listing we already built, plus an earnings estimate from real comparables in their own city
CLAIMEDToken spent, listing.claimedAt stamped, the account is theirs.A short screen-recorded walkthrough of the host console
COMPLETINGThe listing is missing photos, a price, a description or amenities. The bot walks them through one gap at a time, not all at once.Photo guide, free photo re-hosting, description help
LIVEPasses listing-qualification-gate.ts; wave-publish flips Busy → Available in the open city."You are live", plus first-booking tips

The retention loop — recurring, and the real ongoing work

A free marketplace's funnel lives here, and it never terminates:

  • Confirm availability. On a cadence: still available, or busy? One tap either way.
  • Support. Answer what they ask — and reach out unprompted when telemetry says they are stuck: claimed but no photos, live but no views, views but no enquiries.
  • Guides, sent when the gap actually appears rather than dumped at signup.
  • Feedback, at defined moments: a week after going live, after the first booking, and on any churn signal.
  • Stale → busy. Availability unconfirmed past the threshold flips the listing to busy automatically, and then asks.

That last rule carries more weight than the four above it, and the reason is worth stating plainly: a guest who books a unit that is actually occupied does not come back. In a marketplace with no subscription to cancel, stale availability is the churn mechanism. So the highest-value recurring touch in all of mkan is also the cheapest — a two-word question.

What the guides are made of

Host content is produced by the engine's existing media spells and referenced by URL, never generated per host:

KindSpellUsed for
Text/draftThe touch copy itself, Arabic-first
Image / slides/carouselPhoto guide, pricing explainer, "what a good listing looks like"
Video/recordScreen recordings of the real host console — the walkthrough, adding photos, flipping availability
Video / imagery/higgsGenerated marketing pieces where a real screen recording is not the point

Segmentation — the customer department

A deterministic key, recomputed on every read, that routes four things at once: owner, cadence speed, value asset and price path. hogwarts uses <authority>-<band>-<rail>-<term>. mkan has no price path — it is free — so its key routes help instead: <units>-<readiness>-<wave>-<responsiveness>, deciding which guide to send, how fast the cadence runs, whether a human should call, and whether a silent listing gets flipped to busy.

Bands are not taste — 100 is the free-tier ceiling in the pricing config, 20 is the minimumMonthly 30 ÷ $1.50 floor, 1000 is where enterprise is offered. Rail sets the channel: Sudan is WhatsApp-first, the Gulf and Egypt are email-first, because a landline in a WhatsApp campaign is a silent non-delivery that reads as disinterest.

Segmentation is deliberately not a gate. It is a pure function of facts already collected, so it can never stall, never annoy, and never disagree with itself between the web and WhatsApp lanes.

Give before you ask

Every touch carries a new asset. If a segment has no unsent asset left, the touch does not fire — it opens a human task instead. That single rule is what stops a cadence from decaying into "just checking in", which is how most of them die.

What is being asked for differs by product, and only one of them involves money: hogwarts is working toward a signature, while mkan is only ever asking a host to finish a listing, confirm availability, or answer a question. The rule is the same in both — arrive with something.

Assets are produced by /carousel, /draft and /higgs, pushed to the CDN, and referenced by URL — never generated at runtime. That is what holds the per-lead cost at zero. The two strongest gives are already built and cost nothing marginal:

  • hogwarts — a sandbox tenant carrying the school's own name, seeded with real data.
  • mkan — a listing we already built from their own photos, plus a single-use claim token. The host is not asked to imagine the value; they are handed a finished page only they can claim. After that the giving does not stop, because there is nothing to sell them: the guides, the free photo re-hosting and the support answers are the product relationship.

The follow-up engine

The most important part, and the one most likely to be built badly.

tick        → rows past their stall timer                [0 tokens]
touch 1–2   → existing templates, send unattended
touch 3+    → queued as a draft request
drain       → claude -p on the Max pool, ONE session per tick,
              answers written through FILES not argv
human gate  → approve now | schedule | dismiss           ← required for drafted copy
dispatch    → Evolution WhatsApp | Resend email | human task

The clock is launchd and Vercel cron, never a Twenty CRON workflow. Twenty is down whenever the Mac is asleep, has no automatic retries, and fails rather than queues past 5,000 runs/hour — a cadence that silently loses every overnight window looks exactly like disinterest.

Stop on reply, instantly. A reply freezes the cadence, advances the gate if it parses as the current question's answer, and hands to a human. A reply is a gate transition, not just a stop. Four touches, then DORMANT — never infinite. At +90 days a dormant lead re-enters at the gate it stalled at, with one asset it has never seen.

Where the automation lives

LayerOwnerWhy
Gate statehogwarts PostgresOne writer, one truth
Board movesTwentyInboundEventA durable inbox, so "received" and "applied" are separately observable — the distinction a silent 200 destroys
The boardTwenty, mirroredA human dragging a card is advisory — the webhook records it and opens a review task, never a silent write-back
The clocklaunchd + Vercel cronTwenty sleeps with the Mac
The conversationhogwarts / mkanDeterministic rail; Groq answers free-form
The cards#hogwarts-funnel (private)Every workflow card — opening message + wa.me link — in its own room, not the team channel
Follow-up copyClaude Max poolBatched per cohort, never per lead
AlertsHermes → SlackHot-lead ping, daily digest, pause funnel

Hermes alerts; it does not chat. Its contract stands — the gateway pulls its own work, and Vercel can never call into it.

Zero tokens per lead

Routing, segmentation and scheduling are ordinary code. Groq answers free-form chat messages (already live, a separate budget line). Personalized follow-up copy is drafted in one claude -p session per tick on the Max subscription pool. No Anthropic API key is ever called per lead — the same hard rule the scrape lane carries.

A useful test: run a full qualifying conversation with the chat model disabled. The rail must still complete. If it does not, the routing was never deterministic.

What is built, and what is not

Stated precisely, because "the funnel exists" and "the funnel converts" are different claims and only the first one is true. Three layers landed on three days: the plumbing (08-18), the outbound slice (08-19), and the roll (08-22).

Built, and proven against live systems — not just unit-tested:

  • The inbound receiver (08-18). POST /api/webhooks/twenty verifies Twenty's HMAC, writes one TwentyInboundEvent row and returns. Registered on company.updated + opportunity.updated; proven end to end against production.
  • promoteToLead() (08-18). Prospect → Lead, idempotent; the sentinel tenant is created on demand. School.trialEndsAt — the PILOT gate's 90-day clock, live.
  • The 12-stage ladder + 16 funnel fields (08-18/19), seeded into the live workspace; re-runs plan zero writes.
  • The outreach workflow, ACTIVE (08-22). "School shortlisted → outreach" — trigger, filters, both steps — deployed and activated entirely over the API (the "built by a human in the UI" doctrine was disproved; the five-call sequence and the nextStepIds trap live in hogwarts/scripts/crm/workflow-spec.ts, whose --verify reads ✅). Proven on a throwaway school: trigger fired → Hermes delivered to Slack (delivery_id recorded inside the run) → record stamped CONTACTED/QUEUED → a re-flip with outreachStatus=QUEUED produced no second run. Queue lag is real: a flip fires in ~30–45s, not instantly.
  • The measurement backbone (08-22). scripts/funnel/gates.ts — census + the funnel-gates.json artifact this page's yield ledger diffs. First census: 3,894 schools, 377 reachable + unmessaged. The ledger recorded its first real baseline the same run.
  • The roll (08-22). scripts/funnel/tick.ts — ramped, segment-required, kill-switch honoring. WhatsApp lane flips SHORTLISTED and refuses unless the workflow is ACTIVE; email lane sends touch-1 itself via Resend (databayt.org verified) and requires --reply-to, because stop-on-reply is meaningless if replies land nowhere.
  • The applier, v1 (08-22). scripts/funnel/apply-inbox.ts — a WARM drag is the designed reply signal: capture into Prospect + promoteToLead(); every other board move recorded as advisory, never silently applied.
  • Chatbot capture, v1 + rate limit (08-22). sendMessage throttles (12/min + 120/h per IP+UA) and persists typed emails/phones as Prospects — Arabic-Indic digits normalized, saasMarketing mode only (a tenant school's parents are not our pipeline).
  • The channel (08-22). Workflow cards land in private #hogwarts-funnel (C0BRXUREB8W, Abdout + the @kun bot) via Hermes deliver_extra.chat_id — not in the team's #hogwarts, which 10–30 cards/day would drown. The card's instruction says what is actually left to do — mark outreachStatus = SENT after sending — because the workflow already stamped CONTACTED/QUEUED, and QUEUED is not SENT.
  • The capture alert, create-only (08-23). A NEW identifier in the chatbot emails SALES_NOTIFY_EMAIL (fallback hi@databayt.org — only real if someone reads that box) with the conversation tail. findUnique-before-upsert gates it: without the gate, every later turn in the same chat re-fires the alert and the founder stops reading them.
  • The hourly inbox drain (08-23). /api/cron/funnel-apply on Vercel (Bearer CRON_SECRET, ?dry=1 probe) runs the ONE applier implementation (src/lib/funnel/apply-inbox.ts); the script wraps the same core for dry runs and launchd. Webhook rows stop sitting pending forever.
  • One identifier module (08-23). src/lib/funnel/identifiers.ts — capture, the applier and the funnel scripts share the same normalizeDigits/toE164/emailOf; the rules drifted twice living in two files and do not get a third chance.

Not built, and worth being blunt about:

  • Conversions. Still zero. The machinery above exists to change that, not to be admired.
  • The cadence. Touches 2+ have no clock, no drafting drain, no approve queue.
  • The value ladder. The registry has no URLs — every asset in the table above is a plan.
  • Transcript persistence. Capture keeps identifiers, not sessions; FunnelSession is still design, not schema.
  • Distributed rate limiting. No UPSTASH_* in the Vercel env, so the chatbot throttle is per-invocation in prod — burst protection only until that env lands.

Operational notes worth not rediscovering

  • Never run prisma db push against hogwarts production. There is no _prisma_migrations table — it is db-push managed — and a migrate diff pointed at prisma/schema.prisma instead of the prisma/ folder reports 719 dropped foreign keys and 328 dropped tables. That is an artifact of the argument, not real drift: prisma.config.ts loads models from the folder, so a single file yields an empty datamodel. Pass the folder. Real drift is a couple of dozen lines.
  • Vercel runs prisma generate, never migrate deploy. A schema change must reach the database before the code that expects it ships, or every query on that model fails.
  • mkan's outreach-cadence.ts still reads fixture hosts, and its write path now refuses outright rather than mint placeholder claim links that no HostClaimToken backs. The ladder arithmetic is fixed and tested; what it needs next is a real host query.
  • Twenty workflows deploy over the API — never assume the UI. The web UI is effectively unreachable (unauthenticated visits bounce to app.databayt.org, whose Vercel deployment is paused), and it is not needed: login with the Keychain databayt-twenty-login credentials via getLoginTokenFromCredentials on /metadata (the origin picks the workspace), then the introspection-masked mutations on /graphql do the rest. The full sequence, the nextStepIds trap (a trigger without it produces runs that COMPLETE with every step NOT_STARTED), and the filter shape live in hogwarts/scripts/crm/workflow-spec.ts.
  • A stage flip is not instant. The trigger queue fires in ~30–45 seconds; an assertion read 10 seconds after a flip races the run and reads stale state (measured, the hard way).

Guards

  • funnel-guard (PreToolUse) — blocks an --apply that sends with no --segment, and a drain that names no approval source. An unsegmented blast erases the whole reason this lane exists.
  • funnel-yield (PostToolUse, async) — appends the gate delta to .claude/logs/funnel-runs.log, and logs UNMEASURED rather than passing a stale zero off as a measurement.