π raw markdown for agents/LLMs: /FUNNEL.md Β· all docs
Bluebut Funnel β the self-owned acquisition pipeline (ZERO fb-bot)
β STATUS 2026-07-08 β Layers 1-2 SHIPPED + committed (
4eebae3), 3-5 next
- Layer 1 Harvest β DONE (wire scrapers, pre-existing).
- Layer 2 Judge β SHIPPED.
classify-posts(browserlessStrategy.PUBLICjob) +_lib/llm.js(onellm.hostbun.ccclient,claude-haiku-4-5) +_lib/judge.js(ONE combined classify+extract call). NAMING CHANGED from this doc's draft: instead offb_judgment+housing_extract, the output lands in the two standard rental tablespost_extract_listings(offers) +post_extract_seekers(demand) β the operator rejected thefb_judgment/append-only shape as over-engineered (domain nouns, not clever names). A post's type = which table it landed in;fb_post.classified_atmarks it judged;fb_person.intentis the derived cache.- Identity spine (prereq for dedup + outreach) β DONE:
fb_personcanonical onfb_id(74 split humans merged); the 3 duplicate User hubs (fb_person/fb_user/fb_profile) collapsed to ONE.fb_group_membermember is an ACTOR (User or Page β no FK). See CLAUDE.md + reference_fb_identity_model memory.- Layer 4 Act β dedup HALF-SHIPPED: the "have we messaged this person? block" guard (
messenger/lib/dm-dedup.jsalreadyMessaged/stampMessaged) is wired intosend-message. The outreach ORCHESTRATION (candidate query β route sender β drain) is still to build.- Layers 3 (Decide/match-listings) + 5 (Measure) β not started.
Design goal: full cutover. Bluebut owns the ENTIRE housing-lead funnel end to end β harvest β judge β decide β outreach β measure β inside its own data plane (
bluebut-db), with no dependency on fb-bot and no read/write to bofrid.dev. This document is the target architecture. fb-bot (fb_scraped_posts,fb_sent_messages,fb_broadcast_posts, the realtime classifier, the approval manager) is retired β see Β§9 Cutover.
0. The one idea
fb-bot models a pipeline with a materialized queue: scrape rows into
fb_scraped_posts β a manager mutates a status column β outreach drains it.
That coupling (a status machine some daemon owns, a realtime trigger, an approval
service) is the architecture we are leaving.
Bluebut models an entity graph + verbs + one log (the house doctrine: one object, one verb, one log). So the funnel is NOT a pipeline:
fb_personis the object. Intent, company-ness, reachability are derived properties of it.- Outreach is a verb (a Job) against that object.
- The DM thread is the log (
fb_thread/fb_message). - The "queue" is a QUERY, never a table. The set of people to contact is a
live
SELECTover the graph, computed fresh on every tick β exactly like bluebut already computes account-routing and the stalest-target sweep. Nostatuscolumn for a daemon to own, no trigger, no approval-manager, no state-machine drift.
Everything below follows from that.
1. The five layers
HARVEST βββΆ JUDGE βββΆ DECIDE βββΆ ACT βββΆ MEASURE
(wire) (typed (a QUERY, (verb + (eval:
enrich) not a tbl) re-read) offline+online+pre-act)
| Layer | What it does | Where it lives | Status |
|---|---|---|---|
| 1 Harvest | wire jobs enrich fb_post/fb_person with STRUCTURAL truth (real fb_id, epoch, body, group geo, questionnaire answers) |
get-group-feed, scrape-new-members (+ passive wires) |
have |
| 2 Judge | 2 LLM passes: classify β append-only fb_judgment, then extract β symmetric housing_extract (offer/demand, krav) |
_lib/llm.js + _lib/judge.js + classify-posts job |
build |
| 3 Decide | categoryβaction matrix + kravβreal-Bofrid-listing match; candidate list = a live query | _lib/match-listings.js + _lib/outreach-select.js |
build |
| 4 Act | compose per-category message β route the sender (runnable+weighted) β wire send β re-read β log | messenger/jobs/outreach.js + _lib/sender-route.js |
mostly have |
| 5 Measure | 3-tier LLM eval: golden-set canary, online drift, pre-action judge | _lib/judge.eval.mjs + canary:classify + reply-rate |
build |
The only genuinely new code is _lib/llm.js, _lib/judge.js, the eval harness,
one select helper, one outreach job, and one UI surface. Everything else is
machinery bluebut already owns (jobs, scheduler, wire harvest, messenger send,
entity graph, job ledger, HyperDX).
2. Layer 1 β Harvest (already wire, already ours)
Two channels feed the graph. Both are WIRE (truth-ladder), both already land in
bluebut-db. No change needed except that they stop feeding fb-bot (Β§9).
- Channel A β admin member-request scraper (
scrape-new-members, WIRE): the highest-value channel. The join questionnaire carries explicit intent + email + registration age + city off the wire βfb_person+fb_person_signal. These leads are pre-qualified withintent_confidence='explicit'and need zero LLM. - Channel B β group feed scraper (
get-group-feed, WIRE): every group post βfb_post(real numericpost_id,permalink_url, epochcreation_time, owningUser, body) + a lazyfb_personfor the poster. Free-text β needs judging.
Both write only bluebut-db. The fbbot-feed.js fan-out to bofrid.dev
fb_scraped_posts is deleted (Β§9).
3. Layer 2 β Judge (TWO LLM passes: classify, then extract)
Same shape as fb-bot's proven flow, on bluebut's rails. Two passes, both to
llm.hostbun.cc, because they answer different questions and the second is only
worth paying for once the first says "housing":
Pass 1 β CLASSIFY (cheap, every unjudged post). Send the post text to
llm.hostbun.cc β typed JSON:
post_typeβrental-listing|listing-seeker|none|unknownis_companyboolconfidence0β100
Gate: confidence β₯ threshold AND post_type β {rental-listing, listing-seeker}
β classified (keep). Else β rejected (off-topic / low-confidence / our own
post). The threshold lives in a config row, not a const, so it's tunable without a
deploy.
Pass 2 β EXTRACT (only for classified posts). A second call with a
direction-specific prompt pulls the STRUCTURED object into ONE symmetric table,
housing_extract, with a direction:
rental-listingβdirection='offer': the shared dimensions (kommun, lan, rooms, area_m2, price_sek=rent,move_in, property_type) +extras jsonb= the offer-only features (total_rent, floor, furnished, pets, balcony, parking, laundry, contact, is_temporary).listing-seekerβdirection='demand': the SAME dimensions (price_sek= budget_max) +extras jsonb= thekrav(requirements):num_occupants, household_type (single|couple|family|students), has_children, has_pets, employment_status, has_references, can_pay_deposit, duration_months, other[].
One symmetric shape β matching a seeker's krav against listings is a single join
on the shared dimensions (Β§4). scrape-new-members leads carry most of this off
the questionnaire wire (explicit) β they enter as direction='demand' extracts
without an LLM call.
_lib/llm.jsβ the ONE client.POST https://llm.hostbun.cc/v1/chat/completions(OpenAI-compatible, headerX-Project: bluebut), model pinned per purpose in an env-overridable const (classifyβclaude-sonnet-5,extractβclaude-haiku-4-5),response_format: json_schemaso replies are typed, bounded retry + a backup-model fallback. Every LLM call in the repo goes through here._lib/judge.jsβclassifyPost(post)(Pass 1) andextractListing(post)/extractSeeker(post)(Pass 2). Build the prompt from the post's wire fields, callllm.js, validate, return the typed struct. Reject phantoms.
3.1 Data model (the robust core β bluebut-db)
The model splits into three layers so it stays sustainable: observation (wire,
immutable) never mixes with inference (LLM judgments, append-only + versioned)
which never mixes with action (the outreach ledger). Person-level intent /
outreach_status are DERIVED caches, never the source of truth. This mirrors the
repo's existing fb_person (state) vs fb_person_signal (append-only log) split.
Lead identity is polymorphic. Two channels feed the funnel β a feed post
(get-group-feed) and a member request (scrape-new-members, has a person but
no post). Everything downstream keys on the agnostic pair
(source_kind, source_ref) β the same shape as the job ledger's
(target_type, target_id) β so both channels ride one pipeline.
-- ββ OBSERVATION β fb_post / fb_person stay PURE WIRE MIRRORS. No judgment columns. ββ
-- (unchanged; the entity graph already holds them)
-- ββ INFERENCE β append-only, versioned. Re-judge = a new row; nothing is overwritten. ββ
CREATE TABLE IF NOT EXISTS llm_prompt ( -- prompts are versioned DATA, not consts
kind text, version int, text text NOT NULL, model text NOT NULL,
created_at timestamptz DEFAULT now(), PRIMARY KEY (kind, version));
CREATE TABLE IF NOT EXISTS fb_judgment ( -- one row per (lead, model, prompt) β APPEND ONLY
id bigserial PRIMARY KEY,
source_kind text NOT NULL, -- 'feed_post' | 'member_request'
source_ref text NOT NULL, -- post_url | member_request_id (the polymorphic key)
fb_person_id text, -- the poster/requester (denormalized for query)
judge_kind text NOT NULL DEFAULT 'classify',
post_type text, -- rental-listing | listing-seeker | none | unknown
is_company boolean,
confidence int, -- 0..100
model text, prompt_version int, -- FKs llm_prompt(kind,version) β reproducible + eval-able
raw jsonb, -- verbatim reply (audit + diff)
created_at timestamptz DEFAULT now());
CREATE INDEX IF NOT EXISTS fb_judgment_src ON fb_judgment (source_kind, source_ref, created_at DESC);
CREATE TABLE IF NOT EXISTS housing_extract ( -- ONE symmetric table, both directions
source_kind text NOT NULL, source_ref text NOT NULL,
direction text NOT NULL, -- 'offer' (landlord) | 'demand' (seeker)
fb_person_id text,
kommun text, lan text, rooms numeric, area_m2 numeric,
price_sek int, -- rent (offer) | budget_max (demand)
move_in date, property_type text, -- apartment | room | house | student
extras jsonb, -- offer: features Β· demand: the krav (requirements)
model text, prompt_version int, confidence int,
extracted_at timestamptz DEFAULT now(),
PRIMARY KEY (source_kind, source_ref));
-- ββ ACTION β the ledger IS the outreach state (one row per ATTEMPT, multi-touch). ββ
CREATE TABLE IF NOT EXISTS fb_outreach (
id bigserial PRIMARY KEY,
fb_person_id text NOT NULL,
source_kind text, source_ref text, -- the lead this outreach answers
direction text, -- offer | demand
profile_id bigint, -- which message_profile rendered it
message_text text, -- the exact text sent (audit + eval)
matched_listings jsonb, -- SNAPSHOT of the Bofrid listings shown (for reply-rate eval)
sender_account text, -- the account that sent it (resolved at send)
job_id text, -- FK to the execution log (job ledger) β like group_post
status text NOT NULL DEFAULT 'draft', -- draft | approved | sending | sent | failed | skipped
skip_reason text, -- cooldown | no_runnable_sender | dedup | quiet_hours | blocked
sent_at timestamptz, reply_at timestamptz,
created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now());
CREATE INDEX IF NOT EXISTS fb_outreach_person ON fb_outreach (fb_person_id, created_at DESC);
-- fb_person: intent + outreach_status become DERIVED CACHES (refreshed from the logs
-- above), never the source of truth. fb_person_signal stays the intent TIMELINE.
-- intent seeker|landlord|unknown Β· intent_confidence explicit|inferred Β· intent_source ...
ALTER TABLE fb_person ADD COLUMN IF NOT EXISTS intent_source text;
-- ββ CONFIG β operator-tunable data, no deploy. ββ
CREATE TABLE IF NOT EXISTS outreach_config ( -- singleton (id=1)
id int PRIMARY KEY DEFAULT 1,
confidence_threshold int DEFAULT 60,
quiet_start time DEFAULT '23:00', quiet_end time DEFAULT '07:00',
account_min_interval_s int DEFAULT 600, send_jitter_min_s int DEFAULT 180, send_jitter_max_s int DEFAULT 300);
CREATE TABLE IF NOT EXISTS message_profile ( -- the Β§4 routing key β templates
id bigserial PRIMARY KEY,
direction text, is_company bool, has_match bool, -- routing key: (direction, is_company, has_match)
version int DEFAULT 1,
templates text[], -- variants; {poster_name}{area}{listings} placeholders
auto_approve bool DEFAULT false, enabled bool DEFAULT true);
CREATE TABLE IF NOT EXISTS sender_pool ( -- tunable per-account weight; runnable gate is DERIVED at runtime
account text PRIMARY KEY, weight int DEFAULT 100,-- 0=disabled Β· <100=probabilistic send-chance (reputation)
blocked_until timestamptz);
Derived views (the DECIDE layer is a query, not a column):
-- current classification = the latest judgment per lead
CREATE OR REPLACE VIEW v_lead_class AS
SELECT DISTINCT ON (source_kind, source_ref) *
FROM fb_judgment ORDER BY source_kind, source_ref, created_at DESC;
-- seeker leads still needing a first touch = the outreach candidate query
CREATE OR REPLACE VIEW v_seeker_leads AS
SELECT c.*, e.kommun, e.price_sek, e.rooms, e.extras AS krav
FROM v_lead_class c
JOIN housing_extract e USING (source_kind, source_ref)
WHERE c.post_type = 'listing-seeker' AND e.direction = 'demand'
AND NOT EXISTS (SELECT 1 FROM fb_outreach o -- dedup from OUR ledger, not bofrid.dev
WHERE o.fb_person_id = c.fb_person_id AND o.status IN ('sent','approved','sending'));
classify-posts β a Job that batches leads with no current v_lead_class row:
Pass 1 β fb_judgment (append), gate, and on keep Pass 2 β housing_extract +
a fb_person_signal intent event. NOTIFY pgrst. Idempotent: same prompt version
= no new row; a bumped version appends, so re-running never loses or double-writes.
Runs on the recurring scheduler or on demand β every run a row in the job ledger β
HyperDX by job_id (fb-bot's realtime trigger was invisible). judge_raw keeps the
raw reply so a re-run after a prompt tweak is diffable.
4. Layer 3 β Decide (category β action matrix + krav match)
This is the heart of what you asked for: classification decides the ACTION, the message CONTENT, and whether it auto-sends or waits for review.
4.1 The branch matrix (category β action)
post_type |
is_company |
krav match? | Action | Message profile | Gate |
|---|---|---|---|---|---|
listing-seeker |
false | listings matched | DM the seeker real Bofrid listings | seeker-match (personalized, injects listings) |
auto-send |
listing-seeker |
false | no match | DM generic "check bofrid.se" | seeker-generic |
operator review |
rental-listing |
false | β | DM the landlord (list on Bofrid / partner) | landlord |
auto-send |
| any | true (company) | β | company profile, or skip if no company config | company-* or none |
per-config |
none/unknown |
β | β | none (rejected at Pass 1) | β | β |
The routing key is (direction, is_company, has_match) (direction derived from
post_type: seekerβdemand, listingβoffer) β look up the enabled
message_profile. No matching profile β the lead is skipped (an fb_outreach
row with status='skipped', skip_reason), never silently dropped. Same logic
fb-bot's pickAutoMessageConfig uses, but as a table you edit, not code.
4.2 The krav β listing MATCH (the value-add)
For a listing-seeker, _lib/match-listings.js takes the extracted krav
(kommun, budget_max, rooms_min, household) and finds the top ~3 real live
Bofrid listings that fit. This is the ONE legitimate external read β the Bofrid
product's own listing inventory (geo-bofrid / the Bofrid platform API), NOT
fb-bot. It's read-only and it's the actual product we're recommending. Matches β
the personalized seeker-match profile injects them; no match β the generic profile.
4.3 Candidate selection = a query (no daemon-owned queue)
The work-list is the v_seeker_leads view (Β§3.1): current classification β
housing_extract demand, minus anyone with an open/sent fb_outreach row. Computed
fresh, oldest-first (_lib/outreach-select.js) β the stalest-sweep pattern. Dedup
reads our own fb_outreach ledger (backstopped by the fb_message log at send),
never bofrid.dev.
5. Layer 4 β Act (compose β route sender β wire send β confirm)
A composed DM is a durable row in fb_outreach (the outreach LEDGER β persists
the text, chosen sender, status, audit; the honest successor to fb-bot's
fb_sent_messages, in bluebut-db). Status machine:
draft β approved β sending β sent | failed | skipped. auto_approve profiles land
in approved; others in draft for the operator. A send Job drains approved.
5.1 Message CONTENT varies by category
message_profile.templates[] is picked per the Β§4.1 routing key; one variant chosen
at random (spam-hash variation, anti-automation rule #4); placeholders substituted:
{poster_name}, {area} (=" i {kommun}" or empty), {listings} (the matched-listing
block: β’ Title β 2 rok, 35 kvm, 8243 kr/mΓ₯n\n<url>). Seeker-match profiles inject
real listings; generic/landlord profiles are plain templates. Content is
template-based with DB-driven listing injection β deterministic, auditable, cheap
(no LLM at send). (An LLM-authored opener is possible later via llm.js, same gate.)
5.2 SENDER selection (which person sends)
_lib/sender-route.js picks the account per send, improving fb-bot's lane pool:
- Eligible pool =
sender_poolaccounts that are cloakbox-runnable (thecdp_catalogrungate β the same one the UI now enforces), have a live session, and are members of the target group where relevant. A no-device account is never chosen (that's the bug you hit today). - Weight / reputation β
weight0 = disabled;<100= probabilistic send-chance (a reputation slider); pick weighted-random among eligible. One account is never driven by two jobs at once (a per-account in-flight lock). - Cooldown / quiet hours β per-account floor 1 send / 10 min, global jitter
3β5 min between sends, no sends 23:00β07:00 Europe/Stockholm. A throttled
send is released back to
approved, never failed. - Block auto-disable β an auth/checkpoint/block verdict on the send sets that
account's
weight=0+blocked_until(self-heal doctrine), so it drops out of the pool next tick without a human.
5.3 Send + confirm (never trust the send)
Send via messenger/send-message β LightSpeed (plain) or whatsmeow E2EE
(type-15), through the anti-detect Chrome + keyvault session, NOT a DOM composer.
Then re-read the thread: only mark fb_outreach.status='sent' once fb_message
shows our echo (okWith/failWith). Persist sent the instant the browser
confirms β before slow screenshot/HAR β so a crash can never double-send (fb-bot's
recoverStuckMessages lesson). Write fb_message (the dedup source) + flip
fb_person.outreach_status='contacted' + an fb_person_signal. Replies come back
via messenger/sync-inbox β outreach_status='replied' (the Β§6 signal).
6. Layer 5 β Measure
Two cheap signals, no golden-set harness:
- Confidence gate β Pass 1 already drops low-confidence classifications
(
confidence < thresholdβ rejected); the threshold is tunable inoutreach_config. - Reply rate by prompt version β this is why judgments are append-only + carry
prompt_version: joinfb_outreach.reply_atback to thefb_judgment/llm_promptthat produced the lead andGROUP BY prompt_version. A DMed seeker who replies was classified right; a drop after a prompt change = a bad change, caught in the data. Thematched_listingssnapshot on the ledger also answers "did real listings lift replies vs the generic message". Operator re-tags in the UI store as free corrections.
7. New / changed files
adapters/_lib/llm.js NEW one llm.hostbun.cc client (2 pinned models, json_schema, backup fallback)
adapters/_lib/judge.js NEW classifyPost (Pass 1 β fb_judgment) + extract (Pass 2 β housing_extract)
adapters/_lib/match-listings.js NEW krav β top-N real Bofrid listings (reads the Bofrid product API, read-only)
adapters/_lib/outreach-select.js NEW candidate query (queue-is-a-query)
adapters/_lib/sender-route.js NEW cloakbox-runnable + weighted + cooldown/quiet-hours + block-disable
adapters/facebook/classify-posts.js NEW batch pending leads β Pass1βfb_judgment, Pass2βhousing_extract (a Job)
messenger/jobs/outreach.js NEW drain approved fb_outreach β sender-route β send-message β re-read β log
adapters/facebook/SCHEMA.sql EDIT fb_judgment/housing_extract/fb_outreach/llm_prompt/config tables + views (Β§3.1)
ui/ (Outreach surface) NEW classified leads + matched listings + message draft + approve/send + sender pool
adapters/_lib/fbbot-feed.js DELETE (Β§9)
Every _lib/* module ships a sibling *.test.mjs (house rule). classify-posts
and outreach register their ops in _lib/taxonomy.js.
8. UI β the Outreach surface
A batch page / workspace tab (Ui.tsx + MUI, driven by runJob/startJob):
- Leads β classified seekers/landlords,
post_type+is_company+ confidence, the extracted listing/seeker card, and (for seekers) the matched Bofrid listings. - Message draft β the composed DM per the profile; editable; approve/decline per
lead or bulk.
draft β approvedhere (or auto for auto-approve profiles). - Sender pool β the
sender_poolaccounts with weight sliders + live cloakbox status; disabled/blocked ones greyed. This is where the operator tunes reputation. - Send β one click drains approved via
messenger/outreach; every send is a Job, live in the Jobs dock, HyperDX byjob_id. Or a recurring job runs it unattended with the same gates. No approval-manager service β the operator/schedule is the trigger, the Job is the unit, the ledger is the trail.
9. Cutover β kill the fb-bot seam
Full cutover, in order, no lead dropped:
- Ship layers 2β5 (judge, decide, act, measure) writing only
bluebut-db. - Run in parallel for one week: bluebut classify+outreach AND the legacy fb-bot feed, both live. Compare lead volume + reply rate.
- Flip the feed off β delete
fbbot-feed.js, drop theBLUEBUT_FEED_FBBOTenv, remove thefeedFbbotScrapedPostscall fromget-group-feed. Bluebut no longer writes bofrid.devfb_scraped_posts. - Move dedup home β the two remaining bofrid.dev reads (
fb_sent_messages/fb_broadcast_postsinui/src/lib/util.tsx+ui/src/pages/Messenger.tsx) readfb_message/group_post(bluebut-db) instead. bofrid.dev drops out of the read path entirely. - Retire fb-bot β the manager, realtime classifier, and approval UI are
decommissioned. bofrid.dev stays read-only ONLY as a historical archive during
the transition, then is dropped from the data-planes table in
CLAUDE.md.
End state: the funnel is one repo, one DB, one job ledger. No fb-bot, no bofrid.dev, no cross-DB hop.
10. Why this is strictly better than fb-bot
Same proven lifecycle (classify β extract β match β route β send), rebuilt robust:
| Axis | fb-bot | bluebut (this design) |
|---|---|---|
| Post input | DOM scrape (localized, faked ts, no real id) | WIRE (real fb_id, epoch, body, geo, questionnaire) |
| Classify (Pass 1) | llm.hostbun.cc on a realtime trigger, opaque blob |
llm.hostbun.cc in a Job β typed on fb_post, judge_raw + confidence gate |
| Extract (Pass 2) | haiku β fb_listings/fb_seekers + krav |
ONE symmetric housing_extract (direction offer/demand, krav in extras) |
| Inference storage | judgment columns overwritten on the row | append-only fb_judgment + versioned llm_prompt β reprocess, A/B, eval |
| Categoryβaction | code (queueMessageForPost) |
message_profile matrix table you edit, no deploy |
| kravβlisting match | Bofrid listings | same β reads the Bofrid product API (not fb-bot) |
| Message content | templates + listing injection | same templates + injection, per-profile variants |
| Sender select | lane index + weight, live session | cloakbox-runnable gate + weight/reputation + cooldown + block-disable |
| Queue | materialized fb_sent_messages + manager daemon |
candidate = a live query; composed DM = fb_outreach ledger (no daemon) |
| Send | DOM DM, decaying fb_users sessions |
LightSpeed/E2EE wire, anti-detect Chrome, keyvault |
| Truth check | fire-and-forget | re-read the thread (never trust the send) |
| Dedup | separate history tables (bofrid.dev) | our own fb_message log |
| Cooldown/quiet | per-lane + per-account + quiet hours | same, from sender_pool/outreach_config in bluebut-db |
| Observability | invisible realtime trigger | every step a Job β HyperDX by job_id, copy-log, forensics |
| Evaluation | none | confidence gate + reply-rate by prompt version |
| DB | bofrid.dev (shared, legacy) | bluebut-db (self-owned, FK-linked graph) |