For developers
Set up the repo, understand the backend, run the tests, deploy, and debug a platform reader.
For developers
The stack
| Part | Where | What it is |
|---|---|---|
| Backend | convex/ | Convex: schema, queries, mutations, actions, HTTP routes, crons, scheduler. Also hosts the built web app. |
| Web app | apps/web | Vite + React + TypeScript. Signs in with Clerk, talks to Convex directly. |
| Docs site | apps/docs | Next.js + Fumadocs. This site. |
| Extension | apps/extension | Chrome Manifest V3. Reads your cookies for reddit.com, x.com or facebook.com and produces a token. |
| Helpers | clients/ | Python. x_push.py, facebook_push.py, reddit_push.py, shared listeningkit_ingest.py. |
| Legacy mock API | apps/api | A Hono mock from before the Convex backend. Not on the live path. |
Two Convex deployments: dev determined-cheetah-971 and prod tremendous-seahorse-330. The prod site is https://tremendous-seahorse-330.convex.site.
Set up
pnpm install
pnpm exec convex dev # once, to connect to the dev deployment (needs a seat on the Convex team)
pnpm dev:web # the web app; add pnpm dev:docs for this siteapps/web/.env.local needs VITE_CLERK_PUBLISHABLE_KEY (public by design) and VITE_CONVEX_URL, with VITE_API_MODE=live. The full list is in .env.example.
Deployment environment variables (Convex, never VITE_*)
| Variable | Purpose |
|---|---|
AUTH_ISSUER, AUTH_AUDIENCE | Verify the Clerk sign-in token. |
SESSION_ENCRYPTION_KEY | 32 random bytes, base64. Encrypts connected logins. Without it nobody can connect an account. |
OPENAI_API_KEY | Scores matches. AI_MODEL changes the model (default gpt-4o-mini); AI_SCORING=off turns scoring off. Optional. |
REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET | Reddit "script" app for fresh reads. Optional. |
PROXY_URL | The proxy every X and Facebook helper must use, like http://user:pass@host:port or socks5://.... Mandatory: helpers refuse to run without it. |
PLAN_PHRASES_PER_PLATFORM, PLAN_ACCOUNTS_PER_PLATFORM, PLAN_WEBHOOKS_PER_PERSON | Operator-only: change the plan limits for the whole deployment (defaults 1, 1 and 0: webhooks are a Pro feature). |
PROXY_REQUIRED | Operator-only: set to false to let helpers browse directly on a deployment with no proxy yet. |
FIRECRAWL_API_KEY | Reads the person's website in onboarding (brand:extractFromWebsite). Optional. |
AGENTMAIL_API_KEY, AGENTMAIL_INBOX_ID | Email alerts. The inbox id is the AgentMail inbox the alerts are sent from. Optional. |
Set them with pnpm exec convex env set NAME value (add --prod for production). Never put a value in a commit, a doc or chat.
How data flows
- Reddit. A cron (
convex/crons.ts) runswatch:tickevery 10 minutes. For each Reddit phrase it reads the community: Reddit's official API if configured, else the www feed, else a mirror.keepMetricsstops a feed without scores from zeroing them. - X and Facebook. The helper calls
GET /session?platform=x(your sealed login, decrypted only for that call) andGET /phrases?platform=x, reads the site, then callsPOST /ingest. - Ingest.
convex/ingest.tsvalidates each post, stores it, matches it against your phrases (convex/lib/match.ts), and writes ahitsrow. - Scoring. Each new match schedules
scoring:scoreHits, which asks OpenAI for a 0 to 100 score, an intent and a one-line reason. A 10-minute cron scores anything missed. - Website reading.
brand:extractFromWebsite(public action, needs sign-in) calls Firecrawl'sPOST /v2/scrapewith a JSON schema, cleans the reply inparseBrandFacts, stores it inbrands, andscoring:forScoringadds a short business summary to each scoring prompt. - Email alerts. A 5-minute cron runs
alerts:sweep: for each person with alerts on it picks their strongest fresh unsent matches, sends one plain-text email through AgentMail (POST /v0/inboxes/{inbox}/messages/send), and only then marks them sent. - Web app. Reads
keywords,hits,sessionsand the alert settings through live Convex queries.
HTTP API for helpers
All three take Authorization: Bearer lk_ingest_.... The owner comes from the key, never from the request.
POST /ingest
{ "platform": "facebook",
"posts": [ { "externalId": "pfbid0...", "authorName": "Pat", "body": ["need a bookkeeper"],
"url": "https://www.facebook.com/...", "likes": 1, "comments": 0 } ] }
→ 200 { "accountId": "...", "ingested": 1, "skipped": 0 }
GET /session?platform=x → the cookie jar, decrypted, only for a helper holding a valid key
GET /phrases?platform=x → { "platform": "x", "phrases": ["need a helper"] }
GET /proxy → { "required": true, "proxy": { "server": "http://host:8080", "username": "u", "password": "p" } }
(503 when the deployment has no proxy: the helper must stop)Up to 100 posts per batch. Invalid rows are skipped and counted, not fatal. platform is facebook, x or reddit.
The public API
At /api/v1 on the deployment's site address, authenticated with a key made in the dashboard (lk_api_..., stored as a SHA-256 hash, shown once; convex/apiKeys.ts). Every route goes through apiRoute in convex/http.ts: key check, scope check, a per-minute counter (60 a minute per key), one JSON error shape, no CORS (keys must not live in browsers). Reference: The API.
- Scopes (
convex/lib/scopes.ts):readalways,write:phrases,webhooks. A key made before scopes existed has none stored and meansread. A route declares the scope it needs. - Writing phrases goes through
convex/lib/keywordOps.ts, the same code the dashboard uses, so validation and the plan limit are identical.POST /keywordstakes anIdempotency-Key(tableapiIdempotency, one day). - Webhooks (
convex/webhooks.ts): when scoring stores a score,webhooks:fanOutqueues a delivery for each active webhook that wants it;webhooks:deliversigns (convex/lib/webhookSign.ts, HMAC-SHA256 overtimestamp.body, secret sealed with the same key as connected logins), POSTs with a 5 second timeout andredirect: 'manual', never reads the response body, and retries at 1, 5 and 30 minutes. Five failed deliveries in a row switch the webhook off. Addresses are checked when saved and again at send time (convex/lib/webhookUrl.ts). They are a Pro feature:PLAN_WEBHOOKS_PER_PERSONis 0 unless the operator raises it. - Known limit: the address check cannot resolve DNS, so a public name that points at a private address is not caught by the check itself. Redirects are never followed and response bodies are never read, which limits what such a name could do.
Plans
Everyone is on Free, enforced on the server in keywords:create and the account creators (convex/lib/plan.ts): one phrase and one account per platform. Only new things are refused; existing ones stay. plan:mine reports plan and usage for Settings, Billing. Pro is display-only and nothing charges. Tests that create several phrases per platform raise the limits with vi.stubEnv('PLAN_PHRASES_PER_PLATFORM', '100').
The mandatory proxy
X and Facebook reading always goes through the operator's proxy, and a person never sees or sets one. fetch_proxy in clients/listeningkit_ingest.py asks GET /proxy before any browser starts; launch_chrome(show, proxy) passes it to Chrome. There is deliberately no --proxy or --no-proxy flag. The proxy is never logged, never in an error, and its username and password are added to the helper's hidden-secrets list. clients/tests/test_proxy.py proves with real Chrome and a local proxy that demands a password that traffic goes through it. Limit to know: the helper runs on the person's own computer, so only server-side reading would fully hide the proxy from a technical person. Reddit is read by the Convex server, which cannot use a proxy.
Security rules (do not weaken)
- Every query and mutation calls
requireOwner(ctx)and filters by owner. No table is readable across accounts. - Logins are sealed with AES-256-GCM. No query returns a jar to a browser.
- Ingest keys are stored as SHA-256 hashes and shown once.
- Error messages must never echo cookie values. Tests use obvious fake values.
- Post text from the internet is untrusted: it is fenced inside the scoring prompt, and the reply is parsed and clamped.
Commands
| Task | Command |
|---|---|
| Backend tests (216) | pnpm test:backend |
| Web tests (203) | pnpm --filter web test |
| Python tests (86) | python -m pytest clients/tests (the browser tests start real Chrome and take a few minutes) |
| Type check | pnpm typecheck |
| Lint | pnpm lint |
| Rebuild the extension zip | python scripts/build-extension.py |
| After adding a Convex function | pnpm exec convex codegen |
| Deploy backend to prod | pnpm exec convex deploy --yes |
| Deploy the web app to prod | pnpm deploy:site (dev copy: pnpm deploy:site:dev) |
Convex tests use convex-test, which finds functions only through the modules map in the test file. List every module you use. Web tests blank VITE_CONVEX_URL, so run them from apps/web (or with --filter web).
If you change the token format in apps/extension/lib.js or convex/lib/token.ts, keep apps/web/src/lib/__tests__/extension-contract.test.ts green and rebuild the zip.
Testing a platform reader honestly
A stand-in HTML page proves your code; it does not prove the real site. Both X and Facebook passed their stand-in tests and then needed a fix on the first real run (X rejected Camoufox, Facebook used a different page layout). So:
- Test the reader in real Chrome against a stand-in page (see
clients/tests/test_x_browser.py,test_facebook.py). - Check it once against the real site with a fake login. It must report "login refused", not crash.
- Run it with a real throwaway account before you call it done.
When X or Facebook changes its page
The readers depend on page structure, so they break when the site changes. To fix one:
- Set the debug folder and re-run with
--show:export LISTENINGKIT_FB_DEBUG_DIR=/tmp/fb # or LISTENINGKIT_X_DEBUG_DIR for X python clients/facebook_push.py --phrases "need a helper" --count 5 --dry-run --verbose --show - When it finds nothing it writes
fb_page.png(orx_page.png), the page text*.txt, and for Facebook the page HTMLfb_page.htmlwithout scripts. - Read the screenshot first: is the site showing results, a login page, "Something went wrong", or a checkpoint?
- If results are showing but not read, find the real markup in the saved HTML and change the selectors in
clients/facebook_browser.py(EXTRACT_JS) orclients/x_browser.py. - Add a stand-in test for the new layout, then re-run for real.
Things learned the hard way: X rejects Camoufox's browser fingerprint even with a good login, so the readers use real Chrome via Playwright. Facebook only fills in a post's address while the mouse is over its timestamp, so the reader hovers each one and falls back to a stable id plus a search link. The reader also keeps only posts whose text contains the phrase, because sites pad quiet searches with unrelated posts.
Adding another platform
- Add it to
convex/lib/token.ts(cookie names and domains), theplatformunion inconvex/schema.ts, andapps/extension/lib.js. - Write a reader class with
search_posts(phrase, count)likeclients/facebook_browser.py, raisingUnauthorized,TooManyRequestsandAccountLocked(matched by class name). - Write a
*_push.pythat callsx_push.run_once(args, client, phrases, platform=..., search=..., to_post=...). - Add it to
LIVE_PLATFORMSinapps/web/src/lib/platform-support.tsand to the helper note inDashboardKeywordsLive.tsx. - Test as above, and run the extension contract test.
Ownership and what is left
Who owns the GitHub repo, the Convex team and the Clerk app, and the ordered to-do list (OpenAI key, Reddit app, submission), are in HANDOFF.md. The public build log is hackathon.md.