πŸ“„ 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

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:

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).

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:

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:

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.

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:

  1. Eligible pool = sender_pool accounts that are cloakbox-runnable (the cdp_catalog run gate β€” 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).
  2. Weight / reputation β€” weight 0 = 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).
  3. 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.
  4. 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:


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):


9. Cutover β€” kill the fb-bot seam

Full cutover, in order, no lead dropped:

  1. Ship layers 2–5 (judge, decide, act, measure) writing only bluebut-db.
  2. Run in parallel for one week: bluebut classify+outreach AND the legacy fb-bot feed, both live. Compare lead volume + reply rate.
  3. Flip the feed off β€” delete fbbot-feed.js, drop the BLUEBUT_FEED_FBBOT env, remove the feedFbbotScrapedPosts call from get-group-feed. Bluebut no longer writes bofrid.dev fb_scraped_posts.
  4. Move dedup home β€” the two remaining bofrid.dev reads (fb_sent_messages / fb_broadcast_posts in ui/src/lib/util.tsx + ui/src/pages/Messenger.tsx) read fb_message / group_post (bluebut-db) instead. bofrid.dev drops out of the read path entirely.
  5. 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)