πŸ“„ raw markdown for agents/LLMs: /CLAUDE.md Β· all docs

Bluebut β€” developer guide

β›” NEVER EVER DO THIS β€” couple logic to the presentation layer instead of the data model

A separation-of-concerns / layering violation: control flow must depend on the stable data contract (the model/state), never on the rendered output meant for humans (display strings).

Sub-names, same root:

This repo's positive form is the truth ladder: read the source of truth β€” cookie/jar β†’ inlined Relay state β†’ persisted GraphQL doc_id β€” and treat the rendered DOM as the last resort. Matching localized innerText (e.g. "TillΓ₯t alla cookies") is the bottom rung dressed up as a check. Correct example: detect FB's cookie banner via the server-rendered flag "shouldShowCookieBanner":true, not the button's label. Skills: surface-extract, local-browser.

Operationally this means: new extraction adapters are WIRE-BASED GraphQL replay, and the legacy DOM scrapers are a migration backlog. See ⭐ Extraction doctrine under Adapters.

Facebook outreach operator dashboard for the Bofrid housing-acquisition funnel. Groups are the primary object today, but the spine (jobs, accounts, data planes) is site- and object-agnostic β€” a page, a person, any object on any site rides the same machinery.

Four pages: Groups (home, / β†’ /groups) + Jobs, Accounts, Tools. Opening a group is a full-page workspace (/groups/<id>[/<tab>], pages/groups/GroupWorkspace.tsx), tabs Overview + People (admins). Posting is not a tab β€” broadcasting is a cross-group batch from the Groups-index checkbox panel.


Architecture β€” the mental model

One object, one verb, one log. The operator works on objects (groups today); every action is a job; every job's trail lands in HyperDX + the durable job ledger, keyed by job_id. None of it is FB- or group-specific.

🚧 ARCHITECTURE IN MIGRATION β€” chromebox β†’ cloakbox consumer

Bluebut is being migrated from owning a browser fleet (the chromebox + its own fingerprint/proxy/session/HAR/tap stack) to being a CONSUMER of the cloakbox identity platform (gologin/pbox), which owns all of that. Bluebut then owns ONLY its FB domain verbs. The target architecture, remove-list, and current status are inlined below.

β›” TRANSPORT β€” consume cloak-cdp over its HTTP API, NOT the MCP

The platform is reached over two surfaces β€” keyvault (pick accounts) and cloak-cdp (launch + drive). For everything bluebut runs unattended (the runner bin/bluebut-adapter.mjs, the bridge, bin/* scripts, cron), cloak-cdp is consumed over its plain HTTP API, NOT the MCP JSON-RPC endpoint. The MCP (.../mcp) is for interactive agent sessions; it can be absent/interactively-authed in headless/cron runs (the exact failure the harness warns about) β€” so runtime code MUST use the HTTP API.

  • Base: https://cloak-cdp.hostbun.cc Β· auth: ?key=ddash or header X-Api-Key: ddash (env CLOAK_API_KEY).
  • POST /api/call body {"tool":"<name>","args":{…}} β†’ {"result":…} β€” the one call for every verb (cdp_load/cdp_save/cdp_catalog/cdp_drive/wiretap_*).
  • GET /api/tools (full input schemas) Β· GET /api/launchable (= cdp_catalog; ?service=facebook&runnable=1&summary=1) Β· GET /api/health.
  • GET /api/tools (full input schemas) Β· GET /api/launchable (= cdp_catalog) Β· GET /api/health (liveness). Quick check: curl -s 'https://cloak-cdp.hostbun.cc/api/health?key=ddash'.
  • Seam: adapters/_lib/cloak-client.js β€” the ONE client, and it speaks POST /api/call (stateless, no handshake). Every verb (cdpLoad/cdpSave/cdpCatalog/cdpDrive/wiretap*) goes through its callTool. Nothing else calls the platform directly; never re-introduce the MCP JSON-RPC transport.

Status (2026-07-09): CUT OVER (code) β€” the runner is cloakbox-only. The BLUEBUT_CLOAKBOX gate is GONE: bin/bluebut-adapter.mjs always cdp_loads β†’ drives β†’ cdp_saves; the chromebox (fb.chrome.blpk.cc) is retired at the deploy level. Live catalog: run=5 Β· mint-new=300 Β· birth=0 β€” only 5 accounts have a Windows cloakbox device; the other 300 need a device minted (per-account human VNC login via add-account). Remaining violations to migrate off: the runner still runs its OWN capture stack (netcapture/har/gql-tap/graphql-store β†’ own MinIO + bofrid.dev) β€” the platform wiretap already captures everything (bluebut-wiretap consumes it). CAPTURE = platform; RECOGNIZE (*-wire.js) = bluebut.

β›” NO CLOAKBOX DEVICE β†’ NO JOB (for that account, for ANY verb)

Every job runs through cdp_load(service, slug), and cdp_load can only load an account that has a Windows cloakbox device (cdp_catalog verdict run). An account with only a mac/linux device or none (verdict mint-new/birth) has nothing to load β€” the job dies at cdp_load with "no seeded device for facebook/ … nothing to load." This is not scrape-specific: it is true for read, scrape, post, admin, messenger β€” every verb. So "can this account run" is exactly "does it have a cloakbox device", nothing else (not logged_in, not membership, not admin-ness).

Live reality: only 5 of 305 facebook accounts are runnable (run=5 Β· mint-new=300). joel-skola, philip, pojtie are among them; most named + all df-* farm accounts are NOT. To make one runnable, MINT a device (cdp_new β†’ human VNC login β†’ cdp_save) via the add-account skill.

Enforced in code β€” never pick a non-runnable actor. The cloakbox-runnable set is getCloakboxCatalog() β†’ makeDrivable(sessions, cloak) (ui/src/lib/util.tsx); drivable(slug) is the hard gate. Every place that chooses WHICH account drives a job filters to drivable FIRST and returns null / [] when none qualifies β€” the action is then DISABLED (with the "mint a device" hint), never fired as a doomed job:

  • GroupWorkspace.pickActor() β€” refresh / scrape / admin-edit actor (drivable is the filter, scoring only orders survivors).
  • broadcastActor.candidatesForGroup() + makeAutoActor fallback β€” the broadcast WHO-posts router (hard-filtered, no non-drivable tail).

The trap this closes: stale fb_account_group edges for purged Page pointers (uthyra-se, bofrid, …) β€” Pages that were never cloakbox accounts (the pointer-model purge removed the kv rows but left the membership edges). A blind memberAccts[0] pick floated one of these first β†’ cdp_load facebook/uthyra-se β†’ dead. drivable excludes them because they have no device. (Cleaning the ghost edges is DB hygiene, separate from the gate.)

Execution path β€” UI β†’ job β†’ bridge β†’ runner β†’ box

operator click
 β†’ useJobs().runJob / startJob   (ui/src/state/jobs.tsx)  β€” UI job: in-memory + toast + HyperDX
   β†’ runCli / runCliLive         (ui/src/api/index.ts)    β€” threads job_id
     β†’ POST /exec/stream (SSE)    (ui/bridge/opencli-bridge.mjs, VITE_BRIDGE_URL)
       β†’ spawn runner             (bin/bluebut-adapter.mjs, env BLUEBUT_JOB_ID)
         β†’ import adapters/<site>/<cmd>.js
           β†’ cloak-client POST /api/call {tool:cdp_load,args:{slug}} β†’ resolvePageWs
             β†’ cloakserve (Windows seed browser on pbox, platform-gated) β†’ Facebook
             β†’ POST /api/call {tool:cdp_save} at the end

The runner is cloakbox-only (the BLUEBUT_CLOAKBOX gate is gone): every FB job calls the platform's cdp_load over the HTTP API (POST /api/call, key ddash β€” see the transport banner above) which resolves+egress-verifies the proxy, gates the fingerprint, and injects the session, then cdp_saves at the end β€” and SKIPS the retired chromebox-only proxy-gate/hardenSession/egress-guard/ensureFbSession (the platform owns them). adapters/_lib/cloak-client.js is the ONE seam; adapters are unchanged.

Three data planes

Under cloakbox: keyvault stays the account store but the platform owns identity writes (cdp_save); the Traffic firehose + HAR planes are RETIRED (platform wiretap captures every seed).

Plane Where Holds Access
Accounts keyvault FB identity / creds / cookies β€” "are our accounts working?" R/W
Entity graph + activity bluebut-db (own Postgres) via bluebut-rest (https://bluebut-rest.blpk.cc, PostgREST) β€” cutover off shared hostbun db.hostbun.cc is DONE/LIVE groups, memberships, people, chats, group_post, the job ledger R/W
Traffic firehose bofrid.dev (supabase.bofrid.dev) har_recording + traffic_entry (every captured request/response + WS frames, ~277k rows/day) + fb_traffic_live (24h-purge live mirror) R/W (the write exception)
HAR blobs bluebut-minio (MinIO/S3, private har bucket) β€” cut over from the hostbun har bucket the full gzipped lossless recordings; traffic_entry.recording_id β†’ har_key β†’ bucket R/W
Legacy history bofrid.dev (supabase.bofrid.dev) fb-bot's sent DMs + broadcasts (fb_sent_messages/fb_broadcast_posts), fb_scraped_posts, fb_ws_captures read-only (except the traffic + scraped-feed writes)

bofrid.dev is read-only EXCEPT the deliberate write tables: the traffic firehose (har_recording/traffic_entry/fb_traffic_live) and the fb-bot scraped-posts feed (fb_scraped_posts). Everything else on bofrid.dev (DMs, broadcasts) stays read-only. The heavy traffic index lives on bofrid.dev, not hostbun, to keep the shared operational DB lean β€” traffic-store.js creds() resolves BOFRID_SUPA_URL first.

Dev principles

  1. One object, one verb, one log. Object = the thing (group/page/person/account); job = every action; HyperDX + job ledger = the trail (keyed by job_id).
  2. Every action is a job. Observability is built in β€” the job ships steps + screenshot-on-fail + one copy-log button. Debug by checking the job first.
  3. Agnostic by default. A job links to what it acted on via the generic (site, target_type, target_id) triple β€” never a site-/object-specific column. New object or site = no schema change.
  4. Self-heal over diagnose-then-act. One smart verb that figures out what's needed beats N manual verbs. ensureFbSession heals cold sessions mid-action. Expose manual rungs as escape-hatches, never the default.
  5. Never trust a click β€” re-read the authoritative end-state. Mutating adapters re-open the menu / reload /people/ and only return ok once the state actually flipped (okWith/failWith). Optimistic UI lies.
  6. One source of truth per plane. Accounts β†’ keyvault. Live entity state + job ledger β†’ bluebut-db (via bluebut-rest; cutover off hostbun db.hostbun.cc DONE/LIVE). Traffic firehose (traffic_entry/har_recording/fb_traffic_live) + scraped-feed β†’ bofrid.dev (R/W). Legacy history (DMs/broadcasts) β†’ bofrid.dev (read-only). HAR blobs β†’ bluebut-minio har bucket. Writing the wrong plane is a bug.
  7. The accounts are the asset. Anti-detect rules are enforced in code, not convention. Survival beats throughput.
  8. Adapters are first-class repo code. Owned in adapters/, picked up on next run β€” no install/symlink. Fork upstream fixes down; don't edit toolbox up.
  9. Fail loud with forensics. failWith() attaches a screenshot + selector strategies tried. Swallow errors only where a comment says it's deliberate.
  10. Precise terminology. cookie βŠ‚ storage_state βŠ‚ session.
  11. Keep the gate green. npm run check (lint + typecheck + build + test) on every push. Errors block.
  12. Run long-running scripts in a subagent, not the main loop. Any script that occupies the session β€” the group-creation queue, the login-box flow, a long scrape/batch β€” launch it via the Agent tool (background or foreground subagent) so the main agent stays free to monitor and inspect the running job (tail logs, check the job ledger / HyperDX, answer questions) instead of being blocked waiting on it. The subagent owns the script's lifecycle; the main agent observes. Don't Bash-block the main loop on a script that runs for minutes.

Running locally

npm install      # repo root β€” @jackwener/opencli (lib) + runner
cd ui && npm install
cd .. && npm run bridge   # terminal 1 β€” local adapter bridge (BRIDGE_PORT, .env β†’ :8790)
cd ui && npm run dev      # terminal 2 β€” http://localhost:5173

β›” LOCAL-FIRST β€” verify locally, DON'T push to test

git push deploys to PROD (Coolify auto-deploys @main). It is NOT a test button. The bridge + adapters + UI are all local code β€” the same files prod runs β€” so every change is observable on your machine in seconds. A push is the LAST step, once the change is proven locally, not a way to see whether it works. Don't burn a prod deploy per edit.

The loop is .env-wired end-to-end: BRIDGE_PORT=8790 + VITE_BRIDGE_URL=http://localhost:8790 (headroom squats the 8787 default, so we run on 8790), so a locally-running UI drives the locally-running bridge β€” same code path as prod. Test recipe by what you touched:

Changed See it without deploying
bridge (ui/bridge/*) restart npm run bridge (no HMR; ~1s boot). curl localhost:8790/health
listing text / compose (_lib/listing-post-text.js + /listings/compose) curl -s localhost:8790/listings/ads?limit=1 β†’ pipe one row into POST /listings/compose β†’ read the text. No FB post, no browser.
adapter (adapters/**) picked up on the NEXT runner spawn β€” no restart. Run it: node bin/bluebut-adapter.mjs adapters/facebook/<cmd>.js --account joel-skola …
messenger ingest (daemon→bridge handleE2eeIngest) the whatsmeow daemon is PROD-only and POSTs to the PROD bridge, so a local daemon can't drive it. Test the bridge's persistence by POSTing a sample DecryptedMessage to the LOCAL bridge: curl -X POST -H "authorization: Bearer $BRIDGE_INGEST_TOKEN" localhost:8790/messenger/e2ee/ingest -d '{"messages":[{"account":"pojtie","thread_key":"<peer_fbid>","message_id":"local-1","sender_id":"<peer>","timestamp_ms":<now>,"message_type":"image","text":"","is_self":false,"attachments":[{"attachment_type":"image","mimetype":"image/jpeg"}]}]}' → it writes the SAME bluebut-db (local bridge reads the prod DSN), so check fb_message/fb_message_attachment/fb_thread/fb_contact. NO prod deploy, no live send.
UI (ui/src/*) Vite HMR β€” already live on :5173, no restart
pure logic (_lib/*) npm test (node --test) β€” a sibling *.test.mjs is faster than any endpoint

Push only a change that (a) is verified locally and (b) is actually meant for prod. Then follow the global "verify after deploy" rule. If a change is bridge/adapter/UI-only, the local run IS the proof β€” the deploy just ships it.

Onboarding new accounts

Cloakbox is the only onboarding path. New account = the add-account skill — creds+proxy→keyvault, cdp_new mint device, cdp_save GROUPED before login, HUMAN VNC login, cdp_save session. NEVER login.py/Mac. Proxy assignment: npm run proxy:pick -- --account <a> --geo SE (proxyless accounts self-provision via _lib/proxybun.js). Known: capsolver self-hosted down → hand-solve captcha. (The old Mac-local login path — local-dev/, login-container/, npm run local* — is DELETED.)

Without the bridge, action buttons fail; read-only pages still work. Single root .env (Vite reads via envDir: ".."; bridge/runner/sniffer/canary/hyperdx all read it):

VITE_SUPABASE_URL=https://bluebut-rest.blpk.cc  # entity graph + job ledger (self-owned bluebut-db via PostgREST)
VITE_SUPABASE_KEY=<service_role_jwt>          # UI-ONLY (baked into the browser bundle at build). Server-side (runner/bridge/canaries) reads this JWT from KEYVAULT β€” `bluebut/config/rest-service-key` β€” not env.
VITE_BOFRID_SUPA_URL=https://supabase.bofrid.dev
VITE_BOFRID_SUPA_KEY=<service_role_jwt>
VITE_CHROMEBOX_URL=https://fb.chrome.blpk.cc  # box: CDP gateway + /chrome/* + /accounts/*
VITE_BRIDGE_URL=http://localhost:8790         # local opencli bridge (8790: headroom squats the 8787 default)

# self-owned data plane (Coolify; cutover DONE β€” entity graph on bluebut-rest, HAR on MinIO)
S3_ENDPOINT=http://minio-malct88jd5gp0miiag4jhslm:9000  # bluebut-minio; HAR blobs β†’ `har` bucket
S3_ACCESS_KEY=<minio_root_user>               # also MINIO_ROOT_USER / MINIO_ROOT_PASSWORD
S3_SECRET_KEY=<minio_root_password>
# BLUEBUT_HAR_BUCKET=har                       # default

Deployment & infrastructure β€” Coolify (hostbun)

Everything runs on the hostbun Coolify (coolify.hostbun.cc, MCP coolify-hostbun), project bluebutchromebox / env production (environment_id=21), one server: funnelsites-bluebut-hexabyte-chrm = Hexabyte e3, 194.104.94.173, Traefik proxy on docker network coolify. (NOT hetzner β€” that old bluebut app is a stale dupe.) Apps auto-deploy from devdashco/bluebut@main on push; one push redeploys UI + bridge together.

β›” WORK ON main β€” NO FEATURE BRANCHES

Coolify deploys bluebut@main, so main IS the deploy branch. Commit straight to main and push β€” do NOT open a feature branch or a PR for normal work. Keep the gate green (npm run check) before every push; a green push deploys. (The one exception is a genuinely risky, long-lived change the operator explicitly asks to stage off-main β€” otherwise always main.)

Apps (project bluebutchromebox)

App Domain Repo / branch Role
bluebut https://bluebut.blpk.cc bluebut@main The Vite UI. VITE_BRIDGE_URL baked β†’ token-gated bridge.
bluebut-bridge https://bluebut-bridge.blpk.cc bluebut@main opencli bridge: {site,cmd} β†’ runner over box CDP. BRIDGE_TOKEN gates; Dockerfile.bridge.
bluebut-db-mcp https://bluebut-db-mcp.blpk.cc bluebut@main MCP over bluebut-db (in the devdash marketplace).
bluebut-e2ee β€” (no domain) bluebut@main Go/whatsmeow E2EE daemon β€” registers an FB E2EE device + decrypts type-15 Messenger bodies. Register-once store = bluebut-e2ee-db.
chromebox-sniffer β€” (no domain) bluebut@main Always-on traffic firehose daemon β†’ HyperDX + bofrid.dev fb_traffic_live. Dockerfile.sniffer.
chromebox-fb https://fb.chrome.blpk.cc Β· https://selkies.fb.chrome.blpk.cc:8081 devdashco/toolbox@chromebox-fb The box: per-account Chromium + CDP gateway (/cdp/<acct>) + /chrome/* + /accounts/* + Selkies. Profiles bind-mounted (survive rebuild).

Data planes β€” the self-owned move (cutover DONE)

bluebut has moved off the shared hostbun Supabase β†’ its own private Postgres + MinIO in the Coolify project:

Resource Coolify name Internal DSN / endpoint Holds
Entity-graph DB bluebut-db postgres://bluebut:***@p3jif8hgier7tzxnsze7ibik:5432/bluebut entity graph + job ledger + messenger. Replaces the shared hostbun Supabase.
REST contract bluebut-rest (PostgREST) https://bluebut-rest.blpk.cc (Traefik strips /rest/v1); PGRST_DB_URI=…authenticator@…/bluebut, same JWT secret so the existing service-role key validates the /rest/v1 surface the UI+adapters speak (Supabase-shaped).
E2EE register store bluebut-e2ee-db private PG the E2EE device register-once Postgres (see messenger/e2ee/PLAN.md).
Blob storage bluebut-minio (MinIO/S3) internal http://minio-malct88jd5gp0miiag4jhslm:9000; env S3_ENDPOINT/S3_ACCESS_KEY/S3_SECRET_KEY, BLUEBUT_HAR_BUCKET (default har) private har bucket (HAR blobs β€” account-takeover material) + fb-media. Replaces Supabase storage.

Migration status: DONE / LIVE. Entity graph + job ledger served by bluebut-db via bluebut-rest.blpk.cc; HAR on MinIO; chromebox_sessions retired (keyvault sole account store). bofrid.dev unchanged β€” traffic firehose + scraped-feed (R/W) and legacy DMs/broadcasts (read-only) stay there.

Secrets are not in this file β€” S3_*, BRIDGE_TOKEN, JWT/service-role keys, DB passwords live in keyvault + the Coolify app env (*** masked). Internal DSN hosts are the Coolify resource UUIDs, reachable only on the coolify docker network. Observability β†’ shared hyperdx-v2 (otel.hyperdx.hostbun.cc, ServiceName='bluebut'/'chromebox-traffic').

Tech stack

Conventions

Session terminology (use these exact words)

cookie  βŠ‚  storage state (storage_state)  βŠ‚  session (the account record)
c_user      { cookies[], origins[] }           { storage_state, fb_id, status, proxy, … }

jar survives only as casual shorthand + stable identifiers (decodeJar, jar-truth.js) β€” don't introduce it in new code.

Quality gate β€” npm run check

CI (.github/workflows/check.yml) runs it β€” plus the ui build β€” on every push to main + every PR. ERRORS block; WARNINGS tolerated. Coolify auto-deploys main independently, so CI is the TRIPWIRE (revert on a red βœ—), not a deploy gate. Run npm run check locally before every push.

DB schema β€” NO ORM, NO migration tool (deliberate)

There is no Drizzle/Prisma/Kysely and no migration runner β€” and there shouldn't be. The DB is reached ONLY over PostgREST REST (_lib/db.js, UI dbGet), so an ORM's query layer would go unused; and the schema is self-extending at runtime (fb_schema_ingest RPC + ~77 ADD COLUMN IF NOT EXISTS), which a versioned-migration tool would fight. The three SCHEMA.sql files ARE the schema-as-code β€” idempotent (CREATE TABLE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS), heavily commented, rebuildable; re-applying the whole file converges prod to the file. Apply them by hand via the bluebut-db MCP (or psql) β€” that is the "migration" step; there is no schema:apply script (running DDL from Node would need pg, an unwanted dep).


Jobs β€” every action is a job (the agnostic spine)

Every operator action runs as a job β€” the only way the UI calls an adapter. The job is the unit of work and observability; not tied to groups or FB.

Taxonomy β€” four layers (think Skyvern: WorkflowRun β†’ Task β†’ Step β†’ Action)

Layer Term What it is Identity
Envelope Batch one operator intent fanned across N targets (bulk post/admin/scrape). A single action is a batch of one. batch_id
Unit Job exactly ONE adapter run against ONE target (or a self-driven chain). The atomic observable + retryable unit. job_id
Phase Step a named phase inside a job β€” step() β†’ job.event(). Drives the timeline. β€”
Atom Action the atomic CDP op a step performs (click/type/nav/GraphQL). Implicit β€” the chromebox-traffic trail. β€”

Source of truth: adapters/_lib/taxonomy.js (LIFECYCLE/TERMINAL, ActionType, OPS with read/write per adapter, classify/rollup/opFor). UI re-declares the static enums in ui/src/lib/taxonomy.ts (no cross-boundary import); taxonomy.test.mjs guards drift. New automation β†’ the new-automation skill.

The durable job ledger β€” object-agnostic (adapters/SCHEMA.sql)

One generic job table in the entity-graph DB (bluebut-db; site-agnostic schema at root; per-site tables in adapters/<site>/SCHEMA.sql). Durable record behind the bridge's wiped-on-restart in-memory list.

CREATE TABLE job (
  job_id text PRIMARY KEY,   -- BLUEBUT_JOB_ID (same id threaded to HyperDX)
  batch_id text, site text, cmd text NOT NULL, op text, access text, account text,
  target_type text,          -- 'group' | 'page' | 'person' | 'account' | null
  target_id text, target_url text, target_name text,
  status text NOT NULL,       -- running | ok | failed | partial | blocked | content_unavailable
  ok boolean, aborted_reason text, summary jsonb,
  har_key text,               -- bucket key of this job's full HAR: job/<job_id>.har.gz
  started_at timestamptz, ended_at timestamptz, duration_ms integer,
  created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now()
);
-- indexes: (target_type,target_id,created_at DESC), (created_at DESC), batch_id, (account,created_at DESC)

The link is the generic (target_type, target_id) pair. Any object's history = same store, no schema change: a group's jobs = target_type='group' AND target_id=<fb_group_id>; page/person/account analogous. site records which adapter family ran it.

The model β€” ui/src/state/jobs.tsx

Observability is built in

Where jobs surface

Rule for any new feature: run it through a job, ship steps by job_id, link via (target_type, target_id), attach a diagnostic on failure, expose copy-log.


Adapters β€” project-owned, run via the local runner

Everything an adapter needs lives in this repo β€” no install, no symlink, no global opencli:

bluebut/
  bin/bluebut-adapter.mjs   # the runner β€” argv β†’ register β†’ CDP β†’ JSON. Its `finally`
                            #   is the post-job pipeline: stop capture β†’ saveGraphqlCalls
                            #   β†’ mineHar (atlas) β†’ harvestEntities (PASSIVE WIRE) β†’ buildHar
  adapters/
    SCHEMA.sql              # site-agnostic shared schema (the `job` ledger)
    _lib/                   # shared helpers β€” NO adapter is a "site"; everything cross-
                            #   adapter lives here, ESM, with a sibling <name>.test.mjs:
                            #     netcapture.js   β€” CDP capture (GraphQL bodies + WS frames)
                            #     fb-schema.js    β€” the ONE Relay decoder (parseGqlJson,
                            #                       extractInlinedBlobs, mineTypes/mineHar)
                            #     passive-wire.js β€” ambient harvester: WIRES registry +
                            #                       harvestEntities() (reuses fb-schema's decoder)
                            #     db.js           β€” the one PostgREST write client (auto-steps)
                            #     sessions.js Β· group-sync.js Β· taxonomy.js Β· diagnostics.js Β· …
    facebook/<cmd>.js       # one cli({...}) per file (active/pull adapters)
    facebook/SCHEMA.sql     # per-site FB entity tables (groups, people, members, …)
  messenger/                # the Messenger box (site:'messenger') β€” its OWN subsystem,
                            #   NOT under adapters/ (jobs/, lib/, decode.js, e2ee/)

Where new extraction code goes (by the two axes β€” see the Extraction doctrine):

opencli is a library (@jackwener/opencli/registry for cli({...}) + Strategy); the runner does dispatch + CDP lifecycle. Pinned to 1.7.8 (1.7.22+/1.8.x enforce access:'read'|'write' on every cli({...}) β€” a bounded future change).

⭐ Extraction doctrine β€” wire-based GraphQL replay is the DEFAULT; DOM scraping is DEPRECATED

This is the operational form of the truth-ladder rule at the top of this file. Any adapter that READS data from FB reads it off the same persisted-GraphQL wire the page itself reads β€” it does NOT scrape rendered HTML. DOM scraping (innerText / querySelector / role="article" / aria-* / matching localized labels) is the bottom rung: it breaks on every locale change and every FB UI refresh, fakes timestamps, and can't see hidden data (folders, real ids). New read adapters are wire-based, full stop. The legacy DOM read-scrapers are a migration backlog, not a template to copy.

Two axes: where the data lives Γ— how we consume it

Axis A β€” the SOURCE (where a fact lives) has three answers. Axis B β€” the MODE (how an adapter gets it) has two.

Axis A β€” source (3, ranked by the truth ladder):

Source What it is A "wire"? Read it via
GraphQL The persisted-Relay data the page reads β€” whether XHR'd on demand (doc_id + end_cursor) or server-inlined into the document at first paint (<script type="application/json"> blobs: visibility, viewer_join_state, if_viewer_* gates). Same __typename/id records, two delivery paths. Yes replay the doc_id (list-joined-groups) or parse the inlined blob (surface-extract skill)
Socket Realtime push frames β€” Messenger/LightSpeed, MQTT publish+ack, DataGateway RPCs. Lives on a separate CDP target from the page. Yes browser-level WS tap (netcapture wsApply) β†’ _webSocketMessages β†’ ws-decode.js / messenger/decode.js
DOM Rendered HTML / localized innerText / aria-*. Carries no __typename, no stability contract. No β€” the bottom rung, last resort only querySelector (legacy scrapers β€” migration backlog)

A wire = a structured source you read by typed field, not by label β†’ GraphQL and Socket are wires; the DOM is not. "Wire-based" = read off a wire.

Axis B β€” consumption mode (2):

Mode What it means When
Active (pull) An adapter drives a surface and reads the wire it triggers β€” replay the doc_id, parse the inlined node, open the thread. On-demand, per-object. You need a specific object's data NOW. The default for a new adapter.
Passive (ambient) A single agnostic harvester decodes whatever wire traffic already flowed on any job and writes the freshest value home β€” job-, site-, object-agnostic. Zero extra FB calls. The fact rides along anyway (a member roster carries User avatars; a group response carries our own actor). Run group-sync, and if our profile pic changed, update it on the spot.

The passive wire β€” adapters/_lib/passive-wire.js (full playbook: the passive-wires skill). Every job buffers its GraphQL + inlined-Relay bodies in-process. harvestEntities(network, ctx) runs in the runner's finally (sibling to mineHar), walks every Relay record once, merge-upserts what it recognizes. ALWAYS-ON β€” harvest + every capture prerequisite default ON (opt-out !== '0': BLUEBUT_PASSIVE_WIRE/_CAPTURE_GQL_BODIES/_CAPTURE_PAGES/_WORKER_HTTP; WS tap always attaches), at BOTH sites (box runner finally + local rolling capture). Recognizers, three kinds: (1) __typename-routed entries in the WIRES registry; (2) __typename-less signature-detected handlers in collect() (Relay strips __typename off fragments β€” group-about-fragment, join-config, invite); (3) self-scoped freshenOwn* wires. Routed by __typename+numeric id+strong signature, never a DOM label.

⭐ THE FULL-OBJECT MANDATE β€” mirror FB's object into ours. When you wire an entity, harvest every field the node carries β€” the aim is to replicate FB's object in our DB; a field skipped now is an extra FB call (= detection risk) later. Discipline: (1) dump the whole node first and read its full key union before writing the extractor; (2) reassemble across fragments β€” match by signature field + numeric id, merge by id (never assume one node = the whole object); (3) structural fields only (privacy_info.icon_name), a display string only when FB exposes it nowhere else; (4) grow the schema to fit β€” ALTER … IF NOT EXISTS + NOTIFY pgrst (expected, not scope-creep); (5) reject phantoms β€” require a strong entity-only signal + a test; (6) prove it live against bluebut-db. Every wire change updates the docs in the same commit (this field list, the new-automation skill, a memory entry). The group wire is the reference.

The (active) wire pattern (copy from a reference impl, don't reinvent):

  1. The cloakbox wiretap (the surface-extract skill β€” captures every seed's traffic incl. WS) on the target surface β†’ its backing doc_id + variables + the response path (…edges[].node + page_info).
  2. Author adapters/facebook/<cmd>.js with an in-page replay (pageExpr): read fb_dtsg/lsd/uid LIVE from the page, fetch('/api/graphql/', …) with that doc_id, page on end_cursor until has_next_page:false.
  3. Extract by structural field name + stable enum key (viewer_join_state, tab_groups_list.edges) β€” NEVER a DOM label.
  4. doc_id ROTATES β†’ keep it in an env-overridable const and re-capture (cloakbox wiretap) when the query starts erroring; fail loud (failWith) with the doc_id in the message. See the doc_id lifecycle doctrine below for the exact discipline.

⭐ doc_id lifecycle β€” the discipline (enforced by eslint + skill fb-doc-id)

A doc_id is FB's persisted-query id: the server stores the query text; the client sends id + variables + fb_dtsg. How every FB read AND mutation is replayed. We handle it like the mature players β€” hardcoded constants, patched by hand, detected-on-failure.

1. Per-FB-build, NOT per-account. Same id for every account on the same FB release. Per-account/session = the tokens + variables (fb_dtsg/lsd, uid=c_user, vars), read LIVE off the page each run. Never store fb_dtsg.

2. Pin every doc_id in an ENV-OVERRIDABLE const keyed by FriendlyName (mirrors mautrix-meta's hand-patched {DocID, FriendlyName} map):

const DEMOTE_DOC = process.env.BLUEBUT_DEMOTE_ADMIN_DOC_ID || '27831756479762517'; // GroupsCometMembersRemoveAdminMutation

Env override = patch a rotated id without a rebuild. A bare inline doc_id literal is an eslint error β€” named const + env fallback + FriendlyName comment. Two flavors: doc_id (default) and client_doc_id (client-persisted, e.g. IG avatar); capture tells you which β€” don't assume.

3. The PAGE is the truth-source; a const can drift. doc_ids live in FB's JS bundles β†’ discovery = load the page, capture the id (cloakbox wiretap). Rotation check = capture live, grep the FriendlyName, diff vs the const. Read the id live off the page instead of pinning when you can (the Messenger box does β€” zero hardcoded doc_id).

4. Rotation is DETECTED, never scheduled. A stale doc_id can't fake success β€” FB returns a loud PersistedQueryNotFound-class error (HTTP 400, or 200 + {"errors":[…]}), never silent wrong data (the whole reason wire > DOM). failWith() with the doc_id in the message; npm run canary:admin (cron) catches a rotation the day it ships. Re-capture, swap the const/env, done.

Reference implementations (read before writing a new extractor):

External references (prior art, cloned in meta-reverse-engineered/): the "Reverse-Engineering Messenger" case study (intuitiveexplanations.com/tech/messenger β€” FB web request shape); raxod502/unzuckify (our session model: durable jar reused, fb_dtsg re-scraped live, login only on auth-fail; doc_id discovery by grepping the served JS for the FriendlyName β€” what bin/canary-doc-ids.mjs does); mautrix/meta (the Go client decode.js is ported from β€” hand-patched {DocID, FriendlyName} map).

Migration backlog β€” DOM read-scrapers β†’ wire replay (each entry: don't reintroduce the DOM scraper):

Mutations β€” wire replay by default; no DOM fallback (rotation fails loud, caught by npm run canary:doc-ids). Rule #5 binds: never trust the send; re-read the end-state. DONE wire-only (doc_ids): create-group-post (ComposerStoryCreateMutation), create-group (useGroupsCometCreateMutation 37309644325300927), invite-to-group (useGroupsCometInviteLinkCreateWithNotificationBulkMutation 27043683338577412, _lib/group-invite-wire.js), accept-friend-requests (FriendingCometFriendRequestConfirmMutation 27351021931180810, _lib/friend-wire.js), add-friend (FriendingCometFriendRequestSendMutation), update-group-admins promote/demote/remove (GroupsCometMembersInvite/RemoveAdminMutation + GroupsCometRemoveMemberMutation 27148641238078140), approve-join-request (GroupsCometApprove/DeclinePendingMemberMutation 27132805893014911/27119718754378916), delete-group-post (useCometFeedStoryDeleteMutation 36213403264942057, input.story_id=base64 story_gid), set-account-language. Input SHAPE is captured off FB's OWN served JS (the commit-function variables:{input:{…}}); doc_id from _facebookRelayOperation or a browser-level CDP tap (autoAttach flatten β†’ catches worker-target mutations the page misses). Capturing a lazy mutation module needs its UI state to exist first. Email-based FB jobs (invite-email-to-group, accept-group-invite-email, add-account-email) were DELETED.

Runner in-page-fetch capture β€” FIXED (adapters/_lib/gql-tap.js). The in-page page.evaluate fetch('/api/graphql') a replay fires does NOT surface on the CDP Network.* socket (a replay job used to show "0 graphql"), so the passive wires + schema miner + fb_graphql_call got nothing from the jobs richest in FB data. Now the runner mounts an in-page GraphQL tap (buildGqlTapSource β†’ wraps fetch+XHR over the SAME bridge page.evaluate uses, clones each /api/graphql response before the app reads it, reports via the __bbGqlTap CDP binding + a window-array fallback) and foldGqlTap merges them into network.gqlCalls at stop() β€” identical shape to a natively-captured XHR. So every wire-replay job now feeds the passive wires: proven live β€” list-joined-groups went from 0 graphql β†’ folded 13 Β· 20 req Β· 13 graphql β†’ passive harvest 90 group Β· 90 membership. Gated BLUEBUT_INPAGE_GQL_CAPTURE (default ON). The source:'inpage_tap' tag marks folded calls.

Run an adapter

The bluebut-rest DB key is sourced from keyvault (bluebut/config/rest-service-key) by _lib/db.js β€” no env export needed (keyvault needs no creds: defaults to keyvault.hostbun.cc + bearer ddash). The runner is cloakbox-only β€” it resolves the account through the cloak-cdp HTTP API (POST /api/call, key ddash), no OPENCLI_CDP_ENDPOINT:

node bin/bluebut-adapter.mjs adapters/facebook/<cmd>.js --account <slug>
#   β†’ cloak-client POST https://cloak-cdp.hostbun.cc/api/call {tool:cdp_load,args:{slug}}
#     (gates + egress-verifies + injects the session) β†’ drive β†’ {tool:cdp_save}.
#     Only accounts with a cloakbox device run (catalog verdict 'run'; the other
#     ~300 need a device minted β€” add-account skill).
#   Override the platform base/key with CLOAK_API_BASE / CLOAK_API_KEY (default ddash).

β›” NEVER ssh-tunnel to pbox β€” the gateway is PUBLIC

cdp_load returns a public gateway origin (https://cloakbox.hostbun.cc?fingerprint=<seed>). Both drive paths reach it from any box with no tunnel: resolvePageWs β†’ the public wss://cloakbox.hostbun.cc/fingerprint/<seed>/… (opencli page API), and cdp_drive β†’ server-side over the public POST /api/call. Proven live from a laptop: node bin/bluebut-adapter.mjs adapters/facebook/whoami.js --account joel-skola β†’ ok:true, no tunnel. Do NOT ssh -fNL 39555:127.0.0.1:39555 pbox and do NOT set CLOAK_ENDPOINT_BASE to a tunnel β€” that loopback path is retired. CLOAK_ENDPOINT_BASE survives only as a legacy origin-override (private/on-box gateway); unset (the default) uses the public origin. Collapsing to cdp_drive-only (kill the direct ws) is an OPTIONAL simplification, not a reachability need.

Sanity-check the platform + list runnable accounts without launching anything:

curl -s 'https://cloak-cdp.hostbun.cc/api/health?key=ddash'
curl -s 'https://cloak-cdp.hostbun.cc/api/launchable?key=ddash&service=facebook&runnable=1&summary=1'
fbcli() {  # usage: fbcli <account> <cmd> [args…]  (cloakbox-only; over the public HTTP API)
  local acct="$1" cmd="$2"; shift 2
  node "$PWD/bin/bluebut-adapter.mjs" "$PWD/adapters/facebook/$cmd.js" --account "$acct" "$@"
}

From the dashboard

runCli/runCliStream POST to the bridge (VITE_BRIDGE_URL); it maps {site, cmd} β†’ adapters/<site>/<cmd>.js, spawns the runner, streams stderr as SSE. Missing file β†’ 404. Hosted is not view-only: prod bakes VITE_BRIDGE_URL β†’ a token-gated bridge (bluebut-bridge.blpk.cc, Coolify, Dockerfile.bridge); BRIDGE_TOKEN gates it, build bakes VITE_BRIDGE_TOKEN so bridgeAuth() sends the bearer.

Source of truth + discovery

Adapter reference (facebook site)

Bulletproofing: every mutating admin adapter follows never trust a click. failWith() attaches screenshot + context + strategies and reclassifies status:'blocked' on an FB block/checkpoint; okWith() reports the verified end-state. The UI chain (promoteAdminChain/addAccountAsAdmin) adds an independent get-group confirmation β†’ verdict (verified | pending_unverified | blocked | failed). Drift canary: npm run canary:admin runs the full cycle, ships to HyperDX, exits non-zero on drift.


Semantic model

Layer 1 β€” Infrastructure: are our accounts working?

FB account record β†’ keyvault (canonical). Client = adapters/_lib/keyvault.js (MCP keyvault.hostbun.cc/mcp, bearer ddash); sessions.js reads/writes it for site==='facebook'.

One namespace, owner-as-field: facebook/accounts/<slug|id> (ACCOUNT_PREFIX) holds ALL FB accounts. Full rows carry owner=bluebut (R/W); droidfleet farm blobs carry tag df-sync/no owner (droidfleet is their writer, bluebut reads them as a sync source). Tell ours apart by owner, NEVER by a key prefix.

keyvault is the SOLE account store β€” chromebox_sessions is retired. kv_list caps at ~100 β€” use kvListAll()/kvListAccountRows().

β›” PAGES ARE POINTERS, NOT ACCOUNT ROWS β€” the strict pointer model

A Facebook Page has no login of its own β€” FB has no page credentials; you "act as" a Page by overlaying i_user=<page_fb_id> on its managing PROFILE's authed session. So the invariant:

Bridge/UI: GET /accounts/registry (canonical list), POST /accounts/sync-keyvault, POST /keyvault/account (writeback).

The UI/registry sources the account LIST + proxy DISPLAY from keyvault (the sessions query β†’ /accounts/registry); the box /accounts view only AUGMENTS live runtime state (spawn status/pid/idle) β€” never gate account data on box reachability (a truth-ladder violation: read the canonical store, not the presentation surface). All account R/W goes through keyvault.js; chromebox_sessions REST is dead. proxy is keyvault-canonical (only cdp_port is box-local).

~90+ accounts (named + ~80 df-* farm), all account_type='profile' (Pages are pointers). Named profiles: pojtie, joel-skola, mrkoll, william, philip, oskar-wik. Active: pojtie, joel-skola. philip manages 9 pages (incl. bofrid.se).

joel-skola is the TEST account. Use it for any manual/live end-to-end check (cloakbox cdp_load drives, wire probes, passive-wire smoke tests). It's the default cloakbox test account (migrated 2026-07-08, seed 13269) and manages the Uthyra.se page. Don't burn a real outreach account on a test.

Layer 2 β€” Entity graph: what do we know about the FB world?

Entity Table (bluebut-db) Source
FB group fb_group list-joined-groups, get-user-groups, get-group
Account ↔ group fb_account_group β€” is_member only list-joined-groups, get-user-groups
Group member edge (role) fb_group_member β€” fb_user_id is an ACTOR (User in fb_person OR Page in fb_page; no FK) get-group
Person / lead / actor (the ONE User hub) fb_person (canonical on fb_id) scrape-new-members, get-group-feed, passive person wire
Messenger contact (per-viewer) fb_contact messenger sync-inbox
Observable event fb_person_signal scrapers
Group post fb_post get-group-feed, passive post wire
Classified listing (offer) post_extract_listings classify-posts (LLM)
Classified request (demand) post_extract_seekers classify-posts (LLM)
Group-post comment fb_comment get-post-comments, passive comment wire
DM thread/message fb_thread / fb_message messenger box sync-inbox (LightSpeed wire)
Group post log group_post broadcast flow

Layer 3 β€” Activity log: what have we done?

fb_sent_messages (DMs) + fb_broadcast_posts (group posts) live on bofrid.dev β€” legacy fb-bot, read-only (queried for dedup + cooldown). Never write it.


Facebook anti-automation β€” the most important operational constraint

FB monitors posting behaviour across accounts/groups; it shapes every architectural decision. The accounts are the asset β€” temp-block = can't post/scrape/DM; perm-block = gone.

What FB detects: velocity (3+ groups/~1hr spikes risk; 2+ hrs rarely triggers), content fingerprinting (identical text = spam hash), timing regularity (use jitter), cross-account coordination (overlapping content/window), IP reputation.

Rules enforced in code:

  1. Min 8 min between posts/account, randomised 8–15 min. Never fixed.
  2. Max ~4 posts/account/hr. Exceed β†’ route to another account or slow down.
  3. Per-group cooldown β‰₯ 7 days. Check group_post (bluebut-db, posted) and fb_broadcast_posts (bofrid.dev, success) before including; show "last posted N days ago" for override.
  4. Text variation. Never identical text to >3 groups/session β€” warn + prompt.
  5. Stop on block. aborted_reason ~ "blocked"/"restricted" β†’ stop the batch for that account, surface prominently.
  6. Dedup before DM — "have we messaged this person? block". PERSON-level, keyed on the canonical numeric fb_id (messenger/lib/dm-dedup.js alreadyMessaged → checks fb_message is_self across the peer's threads + the fb_person hub messaged_at/outreach_status; stampMessaged on send). Wired into send-message before ALL paths (plain/E2EE/DOM) via --abort-on-prior (default on). The fb-bot legacy fb_sent_messages (bofrid.dev) is folded in as outreach_status='contacted' where resolved (the ~10k→fb_id import is a pending backfill).

Two acquisition channels (don't conflate)

Categorization (in-repo, WIRE + LLM β€” the funnel Β§3, SHIPPED 2026-07-08): classify-posts (browserless job, Strategy.PUBLIC) sends each scraped fb_post to llm.hostbun.cc (claude-haiku-4-5, _lib/llm.js + _lib/judge.js) β†’ ONE typed struct: post_type (rental-listing | listing-seeker | none | unknown) + is_company/company_name + confidence + structured housing fields. By type it writes post_extract_listings (offers: rent/rooms/sqm/area/company/contact…) or post_extract_seekers (demand: budget/desired_area/household/move_in…), stamps fb_post.classified_at, and refreshes the fb_person.intent cache. Replaces the old regex classifyPost + the external fb-bot classifier. NOT fb_judgment/housing_extract (an earlier draft β€” superseded by the two standard rental tables).

Signal β†’ Person promotion: when get-group-feed/the passive post wire resolves a poster, promotePersonFromPost (post-sync.js) upserts fb_person on the canonical fb_id + links the post + appends fb_person_signal 'posted'.

Group post broadcast model

Different from fb-bot's listing broadcaster: content is a campaign message, groups picked manually by the present operator, output β†’ group_post (bluebut-db), dedup (group_url, 7 days), no queue/poller/approval/manager β€” the operator IS the approval; direct runCli per group with live feedback.

Account routing (computed fresh): (1) fb_account_group is_member=true (admin via fb_group_member role=admin ∩ our fb_ids) β†’ (2) filter to active session (last_status='logged_in') β†’ (3) prefer admin β†’ (4) prefer UI-selected β†’ (5) none active β†’ skip no_session.

β›” Three things are called "post" β€” never conflate them

The Broadcast page (ui/src/pages/Broadcast.tsx) is the ONE "one content β†’ many groups" surface (the old separate Listings page was consolidated INTO it 2026-07-09; /listings is now an alias route). Its listing table + the per-listing detail drawer (ui/src/pages/broadcast/ListingDetail.tsx) sit on top of three confusable objects:

Term What it is Table Identity / link
Listing the bofrid rental OBJECT (source content) bofrid_listings on bofrid PROD supabase (remote; read live via bridge /listings/ads) β€” NOT in any bluebut SCHEMA uuid id
Group post (a "broadcast") a FB group post WE authored FROM a listing group_post (bluebut-db) linked to the listing by group_post.listing_id ONLY
Scraped fb_post OTHER people's feed posts fb_post (bluebut-db) post_url; authored_by_us flag

The listing→our-posts link is group_post.listing_id — nothing else. Never a URL/text match, never fb_post. Because the listing object lives on a DIFFERENT database, listing_id is a bare text cross-DB pointer with no FK possible (same shape as the geo slug-pointer) — it can dangle; treat it as a soft reference. "Never broadcast" (zero group_post rows for a listing_id) is a first-class state, not an error.

Two rollup VIEWs already exist β€” use them, don't re-derive:


Where to build it β€” bluebut vs fb-bot vs platform

System Repo DB Role
bluebut devdashco/bluebut bluebut-db R/W Active operator dashboard. Where new code goes.
fb-bot devdashco/fb-bot bofrid.dev (read-only here) Legacy queue/poller/approval for overnight broadcasts. Don't extend. History of record.
bofrid platform admin-fb-leads, geo-bofrid, manager, cron-fb-growth ecosystem + bofrid.dev Listing intake, geo, growth. bluebut consumes results.

Default to bluebut when a human operator does something with FB (a human is present β†’ no queue/poller/approval). Not bluebut for unattended automation (β†’ fb-bot/cron-fb-growth), listing/intake/geo (β†’ bofrid platform), or read-only history display (query bofrid.dev directly).

Recipe β€” "add X for bofrid": (1) FB work β†’ adapter in adapters/<site>/; (2) state β†’ idempotent SCHEMA.sql (site-agnostic β†’ root); (3) UI β†’ workspace tab (per-group) or batch page (cross-group) using Ui.tsx + runCli; (4) enforce anti-automation before any FB write; (5) the operator is the queue.


Traffic capture β€” sniffer + HAR

Entire subsystem is PLATFORM-OWNED under cloakbox: the platform's always-on wiretap captures every seed into a wiretap schema + MinIO + per-session HAR (wiretap_grep_traffic/_search_payloads/_export_har). The sniffer, netcapture.js, har*.js, gql-tap.js, traffic-store.js + the capture loop below all move to the DELETE list once migrated; bluebut keeps only its *-wire.js recognizers, run over the entities a cdp_drive gql returns or the warehouse. CAPTURE = platform, RECOGNIZE = bluebut.

chromebox-sniffer (bin/chromebox-sniffer.mjs) β€” always-on daemon, ships ALL box browser activity to HyperDX tagged by account (polls /chrome/profiles, browser-level CDP per profile, records request+nav). OTLP β†’ service.name=chromebox-traffic. npm run sniffer; bin/Dockerfile.sniffer.

Every job is captured by the RUNNER, not the daemon β€” per call it drops a kind:'job' start/end pair + the full per-request trail (source=runner) carrying account/cmd/job_id. The daemon is a best-effort firehose for traffic the runner can't see (manual noVNC).

Debug from traffic (MCP hyperdx_sql, connectionId='6a0dd8f074371384ffe07f18'):

SELECT Timestamp, SeverityText, LogAttributes['kind'] AS kind, LogAttributes['status'] AS status, Body
FROM otel_logs
WHERE ServiceName='chromebox-traffic' AND LogAttributes['account']='<acct>' AND LogAttributes['job_id']='<id>'
ORDER BY Timestamp;

Rule of thumb: session/nav bug β†’ kind='nav' (/login/?next= = cold/blocked, /checkpoint/); API/permission β†’ SeverityText='error' + gql_name; "reaching FB?" β†’ the job-start/job-end pair + ok/duration_ms.

HAR β€” full-fidelity archive (everything: headers, cookies incl. live c_user/xs UNREDACTED, postData, response bodies up to 16 MB lossless, WebSocket frames as _webSocketMessages β€” Messenger/MQTT publish+ack; HAR 1.2 β†’ Chrome DevTools β†’ Network).


Messenger box β€” read/send DMs through whatsmeow (the bluebut-e2ee daemon)

A self-contained subsystem at repo root messenger/ (NOT adapters/messenger/) that works FB Messenger off Facebook's own LightSpeed (LS) sync wire, not the rendered DOM β€” the truth ladder applied to Messenger. The legacy adapters/facebook/sync-messages.js scrapes localized aria-text (bottom rung: no hidden folders, no real ids, faked timestamps). FB web Messenger has no chat store in the DOM β€” it runs an in-browser LightSpeed DB the server drives with LS.sp(...) ops streamed in a /api/graphql/ response (lightspeed_web_request.payload). The box reads that same wire.

⭐ TARGET ARCHITECTURE (2026-07-09): whatsmeow is the ONE messaging path β€” LightSpeed is being DELETED

Messaging goes through whatsmeow ONLY (the bluebut-e2ee daemon = a registered encrypted-messaging device per account). The entire LightSpeed subsystem β€” the browser sync-inbox/sync-thread jobs, the persistent ls-session.go socket, lightspeed.go/ls-ingest.go, bin/ls-keeper.mjs, bin/poll-messenger-inbox.mjs, messenger/lib/decode.js/ls-procedures.js/passive-messages.js, the /ls-connect//ls-send//ls-ingest endpoints, the ls-keeper cron, MESSENGER_LS_PERSIST β€” is being removed. Say "encrypted messages", never "E2EE".

The model: whatsmeow holds a standing socket per account, decrypts EVERY inbound message (text, image, video, audio, file, sticker, location, contact, view-once β€” handler.go never drops a type) and POSTs it to the bridge. A decrypted message is self-contained (DecryptedMessage: thread_key + sender_id + timestamp_ms + message_id + message_type + text + attachments). The bridge materializes the inbox from that stream (handleE2eeIngest): UPSERT fb_message (+ fb_message_attachment), UPSERT fb_thread so the conversation appears, UPSERT fb_contact with the sender name resolved from the fb_person entity graph (whatsmeow only gives the numeric id). Send = send-message β†’ the daemon's whatsmeow send.

The accepted losses (why this is a deliberate trade): no thread-list enumeration of idle conversations (a thread appears when it gets a message), no history (whatsmeow is realtime-only), no folders/unread, and no plain type-1 threads (older/business/page/group DMs that aren't encrypted are unreachable). Contact names come from fb_person, not a Messenger roster. Keep-alive = the reconnect-sweep (CAT minted via the cloakbox once/~24h; the one browser touch) + a register-once device β€” there is no ls-keeper anymore.

Status: cutover IN PROGRESS. Stage 1 DONE (handler.go captures all types; the bridge builds fb_thread/fb_contact from the stream). Stages remaining: send-message whatsmeow-only; delete the LightSpeed files/endpoints/jobs/cron/env; UI + doc rename. The prose below still describes the old LightSpeed subsystem β€” it is the migration backlog, not the target.

A messenger job's decoded wire + _webSocketMessages sit in its per-job HAR (GET /har/blob?job=<id> β†’ gunzip β†’ DevTools). 0 graphql in the step log is expected β€” LS rides one lightspeed_web_request payload, not named-query traffic.


UI pages

Four pages β€” ui/src/App.tsx. Nav: Groups β†’ Groups; Automation β†’ Jobs; Admin β†’ Accounts, Tools.

Page Purpose
Groups (home, /groups) Metrics + group grid (sort/filter/search, checkbox bulk panel: MultiGroupPostForm, BulkAdminForm, MultiGroupAIJobForm). Row β†’ workspace.
Group workspace (/groups/<id>[/<tab>]) Tabs ["overview","admins"]. Overview = cover/info/location editors + our accounts + recent posts + cooldown + Refresh. People = roster + promote/demote (GroupAdminPromoter).
Jobs (/jobs) Live + historical; expand to watch its tab (CDP screencast).
Accounts Session health, proxy, credential editing; add account; "Sync from keyvault".
Tools One-off CLI β€” run any adapter.

A retired lens (Pipeline/Inbox/Outreach/Scraper) comes back as a workspace tab (per-group) or batch page (cross-group), never a top-level one-off.


Geo association for FB groups (planned)

Each FB group covers a geography. Polygons live in the geo project (geo_regions/municipalities/geo_areas, served by geo-bofrid geo.bofrid.eu/api/geo); fb_group is in bluebut-db β€” cross-DB FKs impossible. Plan: slug-only β€” add geo_slug to fb_group; (geo_level, geo_slug) is the canonical pointer, resolved on demand via geo-bofrid. Out of scope: storing geom, replicating geo tables, moving fb_group into geo.