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:
| Fact | Why the sale needs it |
|---|---|
| Authority | Only a signer can sign |
| Student count | Sets the price — $1.50 × N, floor $30/mo |
| Country | Sets currency and rail: SD → Bankak/Cashi, Gulf/EG → card or wire |
| Curriculum + regulator | Sudanese certificate, IGCSE, ADEK eSIS — decides fit |
| Current system | Paper, Excel or a competitor — the switching cost |
| Term start | Schools buy before a term, never during one |
| A reachable channel | 119 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
replaced — COLD, 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.
| Gate | Prospect.status | Lead.status | Twenty stage | live rows (08-22) |
|---|---|---|---|---|
| COLD | new enriched queued contacted | — | COLD | 3,795 |
| PROSPECT | queued | — | PROSPECT | 21 |
| SHORTLISTED | queued | — | SHORTLISTED (08-19) | 0 |
| CONTACTED | contacted | — | CONTACTED (08-19) | 0 |
| WARM | replied | NEW | WARM | 0 |
| DISCOVERY | promoted | QUALIFIED | DISCOVERY | 0 |
| DEMO | promoted | PROPOSAL | DEMO | 0 |
| TRIAL | promoted | NEGOTIATION | TRIAL | 0 |
| PILOT | promoted | NEGOTIATION | PILOT | 1 |
| PAID | promoted | CLOSED_WON | PAID | 0 |
| DORMANT | dead + dormant:<gate> | ARCHIVED | DORMANT (08-18) | — |
| LOST | dead | CLOSED_LOST | LOST | 77 |
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
| Gate | What happens | What we give |
|---|---|---|
| SOURCED | We scraped the unit and pre-built a draft listing. The host does not know yet. | — |
| INVITED | First 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 |
| CLAIMED | Token spent, listing.claimedAt stamped, the account is theirs. | A short screen-recorded walkthrough of the host console |
| COMPLETING | The 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 |
| LIVE | Passes 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:
| Kind | Spell | Used for |
|---|---|---|
| Text | /draft | The touch copy itself, Arabic-first |
| Image / slides | /carousel | Photo guide, pricing explainer, "what a good listing looks like" |
| Video | /record | Screen recordings of the real host console — the walkthrough, adding photos, flipping availability |
| Video / imagery | /higgs | Generated 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
| Layer | Owner | Why |
|---|---|---|
| Gate state | hogwarts Postgres | One writer, one truth |
| Board moves | TwentyInboundEvent | A durable inbox, so "received" and "applied" are separately observable — the distinction a silent 200 destroys |
| The board | Twenty, mirrored | A human dragging a card is advisory — the webhook records it and opens a review task, never a silent write-back |
| The clock | launchd + Vercel cron | Twenty sleeps with the Mac |
| The conversation | hogwarts / mkan | Deterministic 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 copy | Claude Max pool | Batched per cohort, never per lead |
| Alerts | Hermes → Slack | Hot-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/twentyverifies Twenty's HMAC, writes oneTwentyInboundEventrow and returns. Registered oncompany.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
nextStepIdstrap live inhogwarts/scripts/crm/workflow-spec.ts, whose--verifyreads ✅). Proven on a throwaway school: trigger fired → Hermes delivered to Slack (delivery_idrecorded inside the run) → record stampedCONTACTED/QUEUED→ a re-flip withoutreachStatus=QUEUEDproduced 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 + thefunnel-gates.jsonartifact 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 flipsSHORTLISTEDand refuses unless the workflow is ACTIVE; email lane sends touch-1 itself via Resend (databayt.orgverified) 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 intoProspect+promoteToLead(); every other board move recorded as advisory, never silently applied. - Chatbot capture, v1 + rate limit (08-22).
sendMessagethrottles (12/min + 120/h per IP+UA) and persists typed emails/phones as Prospects — Arabic-Indic digits normalized,saasMarketingmode 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 Hermesdeliver_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 — markoutreachStatus = SENTafter 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(fallbackhi@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-applyon Vercel (BearerCRON_SECRET,?dry=1probe) 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 sittingpendingforever. - One identifier module (08-23).
src/lib/funnel/identifiers.ts— capture, the applier and the funnel scripts share the samenormalizeDigits/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;
FunnelSessionis 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 pushagainst hogwarts production. There is no_prisma_migrationstable — it is db-push managed — and amigrate diffpointed atprisma/schema.prismainstead of theprisma/folder reports 719 dropped foreign keys and 328 dropped tables. That is an artifact of the argument, not real drift:prisma.config.tsloads 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, nevermigrate 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.tsstill reads fixture hosts, and its write path now refuses outright rather than mint placeholder claim links that noHostClaimTokenbacks. 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 Keychaindatabayt-twenty-logincredentials viagetLoginTokenFromCredentialson/metadata(theoriginpicks the workspace), then the introspection-masked mutations on/graphqldo the rest. The full sequence, thenextStepIdstrap (a trigger without it produces runs that COMPLETE with every step NOT_STARTED), and the filter shape live inhogwarts/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--applythat 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 logsUNMEASUREDrather than passing a stale zero off as a measurement.
Related
On This Page
Funnelhogwarts — designed backwards from the invoiceWhat the client must actually give usNo fourth vocabularymkan — the same spine, a different jobThe activation ladder — one-time, ends at LIVEThe retention loop — recurring, and the real ongoing workWhat the guides are made ofSegmentation — the customer departmentGive before you askThe follow-up engineWhere the automation livesZero tokens per leadWhat is built, and what is notOperational notes worth not rediscoveringGuardsRelated