# HeyLead — state of the world, 6 Aug 2026

Written at the end of a long session that brought the product back from a
three-month pause. Read this before touching anything; several of the traps
below cost hours to find and are not obvious from the code.

Companion document: `docs/AUDIT-2026-08.md` (the prioritised bug audit that
started the session). This file supersedes it where they disagree.

---

## 1. What HeyLead is

An MCP-native autonomous LinkedIn SDR. It finds prospects, writes outreach in
the user's own voice, sends it on a schedule with rate limits, watches for
replies, and books meetings.

It runs in **two modes, both live**:

- **Direct mode** — `python -m heylead` / `uvx heylead` as an MCP stdio server
  on the user's machine. Uses their own Unipile account and their own LLM key.
  All real data lives locally in `~/.heylead/data/heylead.db`.
- **Hosted mode** — sign in at <https://heylead.dev>, which proxies to a FastAPI
  backend on Cloud Run backed by Cloud SQL. This is the path a customer takes.

Earlier on 6 Aug the decision was to go *direct-mode only* and the hosted
backend was deleted. That was reversed the same day and the backend rebuilt
from scratch. Do not "clean up" the hosted stack on the assumption it is dead.

---

## 2. Where everything lives

### Repositories (all on `main`, all pushed)

| Repo | Path | Remote |
|---|---|---|
| MCP server / product | `~/Documents/AI in Charge/heylead` | `D4umak/heylead` |
| Hosted backend | `~/Documents/AI in Charge/heylead-api` | `D4umak/heylead-api` |
| Marketing site + dashboard | `~/Documents/AI in Charge/heylead-dashboard` | `D4umak/heylead-dashboard` |

### Cloud

- **GCP project `olnesta-heylead`** (org olnesta.com, billing `015536-9EF8DD-ABCFD5`)
  — everything new lives here.
  - Cloud Run `heylead-api` → `https://heylead-api-555454208002.us-central1.run.app`
    - **`--max-instances=1` is required.** The psycopg2 pool is sized on the
      assumption of a single instance.
  - Cloud SQL `olnesta-heylead:us-central1:heylead-db` (POSTGRES_16, db-f1-micro).
    The app creates its own 34-table schema on boot; there are no migrations to run.
  - Secret Manager holds `DATABASE_URL`, `JWT_SECRET`, `SCHEDULER_SECRET_KEY`,
    `UNIPILE_API_KEY`, `GEMINI_API_KEY`, `GOOGLE_CLIENT_SECRET`. Nothing is a
    plaintext env var any more.
- **GCP project `heylead-api`** (owned by `d4umak@gmail.com`) — *keep it*. It
  holds the OAuth client, the consent screen, and the pending Google
  verification. Deleting it breaks sign-in.
- **Vercel** `heyleads-projects/heylead-dashboard` serves `heylead.dev`.

### Local data (the valuable part)

`~/.heylead/data/heylead.db`, ~576 MB: 4,568 connections, ~4,600 contacts,
4,594 outreaches, ~1.2 M `actions_log` rows. This was never in the cloud and was
never at risk. A pre-re-key backup sits beside it as
`heylead.db.bak-rekey-20260806`.

Config: `~/.heylead/config.json` (mode 644, plaintext — see open items).

---

## 3. What changed on 6 Aug 2026

### Product bugs fixed (in `heylead`)

- **Rate-limit accounting** counted the wrong action types, so daily caps were
  wrong in both directions.
- **Account-id cache** could not be invalidated; added `set_account_id()`.
- **Copilot mode** bypassed every send-time safety guard. Removed.
- **`create_campaign` auto-launched** campaigns instead of saving drafts. Now
  creates `STATUS_DRAFT`; launching is an explicit `run_launch_campaign()`.
- **Retired LLM model ids** — moved to `gemini-3.6-flash` / `claude-sonnet-5` /
  `gpt-5.6-terra` with a quality/fast tier split.
- **Thinking-token headroom** — Gemini thinking tokens count against
  `maxOutputTokens`; without headroom, answers were truncated. Verified live:
  81 thinking tokens to emit one word.
- **Structured outputs** — replaced 183 lines of JSON repair with server-side
  schema enforcement (`ai/schemas.py`).
- **Calendar booker** could return a slot in the past. Removed the fallback.
- **`RETIRED_LLM_MODELS` was wrong** — it listed `gemini-2.0-flash`, which is
  still served (429 quota, not 404). Because `resolve_model()` *discards* a
  configured model found in that set, a user pinning it was silently moved to a
  pricier model and told a false reason.
- **Prompt rendering crashed on 4 of 15 prompts.** `render_prompt` used
  `str.format_map`, which treats every brace as a substitution field — so the
  JSON examples prompts use to show the model its output shape raised
  `Invalid format specifier`. This disabled voice analysis, engagement
  comments, follow-up reasoning and prospect scoring.

Test suite on `main`: **690 passing, 0 failing.**

### Hosted backend rebuilt (in `heylead-api`)

The old Cloud Run service was deleted (its Cloud SQL instance had been deleted
by someone else, so it was a public service that could do nothing while holding
13 plaintext secrets). It was then rebuilt in `olnesta-heylead` with Secret
Manager, and:

- **Account status was read from the wrong place.** The code read
  `data["status"]`, but a Unipile account has no top-level status — it is in
  `sources[].status`. So the lookup was always `""`, hit the "unknown → degraded"
  branch, and every healthy account was reported broken. This also gates
  scheduler work, so it was not cosmetic.
- Config defaults repointed at `olnesta-heylead` and a current Gemini model.

### Site (in `heylead-dashboard`)

- **`heylead.dev` never proxied to the backend.** Every `vercel.json` rewrite
  pointed at a static file, so `/mcp` and `/auth/login-url` 404'd — even when
  the backend was healthy. Added pass-through rewrites for `/auth`, `/api/v1`,
  `/webhooks`, `/calendar`, `/mcp`, `/.well-known/mcp.json`.
- **`VITE_API_URL` pointed at the deleted backend**, baked into the bundle at
  build time. This is why the dashboard showed "Load failed" and sign-in 404'd.
- **The quiz feature was live but untracked** — `src/quiz/` (10 files),
  `quiz.html`, and the rollup input that builds it. Deploys only worked because
  `vercel --prod` uploads the working directory.

### Infrastructure

- LinkedIn reconnected through the hosted UI. New Unipile account id
  `Gr7rw6vCSh6vBL_oQU5eQg` (was `i0vbaNkoQIu__uBR8ZABKA`).
- `scripts/rekey_unipile_account.py --apply` re-keyed 4,568 connections and
  2,941 engagements onto the new id and cleared stale account-scoped settings.
- A **project-scoped org-policy exception** on
  `constraints/iam.allowedPolicyMemberDomains` (`allowAll`) was added to
  `olnesta-heylead`. Without it Cloud Run cannot grant `allUsers` and the
  service cannot be public. The org-wide guardrail is untouched.

---

## 4. How to verify it all still works

```bash
# Site + proxy + backend, one shot
for P in / /quiz /dashboard /mcp /auth/login-url /.well-known/mcp.json; do
  printf "%-26s " "$P"; curl -s -o /dev/null -w "%{http_code}\n" "https://heylead.dev$P"
done
```

Expected: `200 200 200 401 307 200`. The **401 on `/mcp` is correct** — it means
the request reached the backend, which refused it without a JWT. A 404 there
means the Vercel rewrite is broken again.

```bash
# Is LinkedIn actually connected?
python3 - <<'PY'
import json, urllib.request
c = json.load(open("/Users/denyschumak/.heylead/config.json"))
req = urllib.request.Request(f"{c['unipile_api_url']}/api/v1/accounts",
                             headers={"X-API-KEY": c["unipile_api_key"]})
d = json.load(urllib.request.urlopen(req, timeout=30))
for a in d.get("items", []):
    print(a["id"], a["type"], [s.get("status") for s in a.get("sources", [])])
PY
```

Expected: one LINKEDIN account with `['OK']`.

---

## 5. Open work, in priority order

### Security — do first

1. **`.mcpregistry_github_token` and `.mcpregistry_registry_token` are still
   tracked in git and shipped in every PyPI sdist from 0.9.12 to 0.10.183.**
   Verified byte-identical in the published tarball. Revoke both, then remove
   them from the repo and consider yanking affected releases. Only the user can
   revoke these.
2. `~/.heylead/config.json` is mode 644 plaintext holding the Unipile and Gemini
   keys. `chmod 600` at minimum.

### Before sending outreach again

3. **Restart the MCP server**, then run `setup_profile()`. Voice analysis
   crashed before the prompt fix landed; the existing voice signature is intact
   so nothing is degraded, but the re-analysis never completed.
4. **Investigate the 1,046 `error` outreaches** before enabling the scheduler.
   Hypothesis (untested): first-degree-guard failures caused by the connections
   cache being keyed to the dead account id, which the re-key has now fixed. If
   so they may be safely retryable.
5. **Scheduler is off** (`scheduler_enabled: false`) with **155 pending
   outreaches** behind it and 1 active campaign. Turning it on starts sending to
   real people — confirm explicitly before doing so.
6. **3,065 engagements have `account_id = NULL`.** Dedup is on
   `(post_id, account_id)`, so these will not match lookups for the current
   account and HeyLead may comment or react a second time on posts it already
   engaged. There has only ever been one LinkedIn identity here, so assigning
   them to the current account would restore the protection.

### Product

7. **Google OAuth verification** for the sensitive `calendar.events` scope is
   queued with Google. Justification and demo video are already submitted;
   nothing to do but wait, and watch `d4umak@gmail.com` for review mail. Until
   it clears, sign-in shows "Google hasn't verified this app" — click Advanced.
   **Verification is bound to the consent screen in project `heylead-api`, so do
   not recreate the OAuth client under olnesta while the review is in flight.**
8. **PyPI 0.10.183 still documents the dead hosted endpoints.** A release would
   fix the onboarding a new user sees.
9. Optional: `BackendClient` is ~4,493 lines with 116 `is_backend_mode()`
   branches. Now that both modes are real, this is legitimate — but it is worth
   auditing for paths that only ever made sense in one mode.

---

## 6. Traps — things that cost hours

- **`VITE_API_URL` must not be set on Vercel.** It is baked into the bundle at
  build time and goes stale the moment the backend moves. The app should use
  same-origin paths, which the rewrites proxy.
- **The Vercel↔GitHub integration stopped firing around March 2026.** A `git
  push` does *not* deploy. Use `vercel --prod`. The last integration-driven
  deploy was 30 March; a push in August produced no build and no status check.
- **Unipile puts account status in `sources[].status`,** never at the top level.
  Any new status check must read it there.
- **Prompts contain literal JSON**, so never render them with `str.format` or
  `format_map`. `_render_content()` in `ai/prompt_loader.py` handles this.
- **Google Cloud console requires 2SV**, and enrolling second factors is *not*
  the same as switching it on — there is a separate "Turn on 2-Step
  Verification" button, and the console keeps blocking until it is pressed.
- **The MCP server caches `constants` in memory** but imports tool modules
  lazily, so a partially reloaded server throws confusing `AttributeError`s.
  After merging, do a full app restart and probe with **two** different tools
  before trusting it.
- **Don't convert sync DB tests to `async def`.** `get_db()` deliberately
  refuses to run on the event loop; use `asyncio.run(...)` and keep the test
  synchronous. Doing otherwise broke 23 passing tests once already.

---

## 7. Credentials — where, not what

Nothing secret is written down here on purpose.

| What | Where |
|---|---|
| Unipile DSN + API key | `~/.heylead/config.json`, and Secret Manager `UNIPILE_API_KEY` |
| Gemini key | `~/.heylead/config.json`, and Secret Manager `GEMINI_API_KEY` |
| Google OAuth client secret | Secret Manager `GOOGLE_CLIENT_SECRET` (rotated 6 Aug; the older one, `****YnwH`, is still enabled and can be deleted once sign-in is confirmed) |
| JWT / scheduler secrets | Secret Manager, generated 6 Aug |
| Postgres app password | Secret Manager `DATABASE_URL` |
