Skip to content

feat(leads): alert on intent, and match on what you sell - #160

Merged
ralyodio merged 1 commit into
masterfrom
feat/intent-alerts-and-description
Jul 28, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feat/intent-alerts-and-description

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Adds prd/0001-intent-lead-monitoring.md — a read of Leadmatically ($19–$1,499/mo) against what we shipped this week — and implements its two P0s.

What they have that we don't

Their scoring is comparable to ours, and in two respects ours is stricter: we refuse people who wrote "no vendors", and we refuse drafts stating anything the campaign never claimed. They beat us on everything that happens after a signal is found — which is where the value of finding it goes.

them us (before)
Instant alerts (Email/Slack/Telegram) ✅ ❌
Score against a business description ✅ keywords only
Saved reply prompt + drafted reply ✅ Reddit only, not configurable
Outcome tracking per conversation ✅ column exists, nothing writes it
Per-platform analytics ✅ ❌

R1 — we were finding requests and telling nobody

A conversation worth answering has a useful life measured in hours. A signal sitting in a table until someone opens the Leads page is indistinguishable from one never found.

  • Batched per campaign per sweep. Eleven emails for eleven conversations is the fastest way to get filtered to a folder.
  • Count and best score in the subject, so it's triageable from a phone notification.
  • Dedupe is a column on the row, not a time window — comparing found_at against a last-alerted timestamp double-sends anything that arrives mid-compose.
  • Marked alerted only after the send succeeds. The other order loses a batch permanently to one SMTP hiccup.

R2 — keyword matching drops the best requests

"Our checkout keeps timing out whenever we run a promo. Anyone recommend a fix?"

Exactly the buyer a load-testing campaign wants. Contains none of its keywords — because people with a problem describe the problem, not the product category that solves it.

A campaign can now describe what it sells in prose, and a keyword miss becomes a question rather than a verdict. The path is deliberately narrow:

  • only judges signals the cheap path already scored above the bar
  • capped per sweep, so an index returning 300 results can't become 300 model calls
  • cannot overturn a stated no or rescue somebody advertising their own services
  • must quote the words that decided it — a keyword match is wrong in obvious ways; a model judging relevance is wrong in ways nobody sees unless it says why

Deliberately not copied

  • Facebook/Instagram monitoring. Neither carries meaningful public buying intent, neither is readable without access we don't have. Listing them would be coverage we can't deliver.
  • Per-keyword subscription tiers. A second quota beside credits makes the cost of a run unpredictable — the one thing this week's billing work was for.

Checks

  • tsc --noEmit clean · 1,100 tests pass (13 new) · build compiles
  • Migration applied to prod

Remaining in the PRD: R3 outcome tracking, R4 per-source analytics, R5 reply prompts, R6 Slack/Telegram, R7 keyword buckets.

🤖 Generated with Claude Code

Adds prd/0001, a read of Leadmatically against what we shipped this week.
Their scoring is comparable to ours and in two respects ours is stricter —
we refuse people who wrote "no vendors", and we refuse drafts that state
anything the campaign never claimed. They beat us on everything that
happens after a signal is found, which is where the value of finding it
goes. This implements the two P0s.

We were finding requests and telling nobody. A conversation worth
answering has a useful life measured in hours, and a signal that sits in a
table until somebody opens the Leads page is indistinguishable from one
that was never found. Alerts are batched per campaign per sweep, because
eleven emails for eleven conversations is the fastest way to make the
feature something people filter to a folder. The count and best score go
in the subject so it is triageable from a phone. Dedupe is a column on the
row rather than a time window, and the row is marked only after the send
succeeds — the other order loses a batch to one SMTP hiccup.

And we matched on keywords alone, which drops the requests most worth
having. "Our checkout keeps timing out whenever we run a promo" is exactly
the buyer a load-testing campaign wants and contains none of its
keywords, because people with a problem describe the problem rather than
the product category that solves it. A campaign can now say what it sells
in prose, and a keyword miss becomes a question rather than a verdict.

That path is deliberately narrow. It only judges signals the cheap path
already scored above the bar, it is capped per sweep so an index that
returns three hundred results cannot become three hundred model calls, it
cannot overturn a stated no or rescue somebody advertising their own
services, and it has to quote the words that decided it — a keyword match
is wrong in obvious ways, a model judging relevance is wrong in ways
nobody sees unless it says why.

Two things in their product are deliberately not copied. Facebook and
Instagram monitoring, because neither carries meaningful public buying
intent and neither is readable without access we do not have, so listing
them would be coverage we cannot deliver. And per-keyword subscription
tiers, because bolting a second quota beside credits would make the cost
of a run unpredictable, which is the one thing this week's billing work
was for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio merged commit 925da5e into master Jul 28, 2026
7 of 8 checks passed
@ralyodio
ralyodio deleted the feat/intent-alerts-and-description branch July 28, 2026 17:25
ralyodio added a commit that referenced this pull request Jul 30, 2026
A backup of a local environment file was committed to master in 925da5e
(#160, 2026-07-28) and has been tracked ever since. It carries live
credentials, so gitleaks has been failing on every PR since that merge.

The old ignore list named each env variant explicitly — .env, .env.local,
.env.development and so on — so a .env.bak-<date> matched none of them.
Replaced with `.env*` plus a negation for the checked-in .env.example,
which fails closed instead of open.

This stops the file being carried forward and closes the gitignore gap.
It does NOT clear the gitleaks check: that job runs `gitleaks detect
--source .`, which scans history, so the findings still resolve to
925da5e regardless of what HEAD looks like. See the PR for the options.

Nor does it undo the exposure — the values are in the repository's
history and need rotating on their own schedule.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Jul 30, 2026
`gitleaks detect` scans history, so the 21 findings from the environment
backup committed in cfb23d2 (#160) keep failing the scan no matter what
HEAD contains — untracking the file did nothing for them. The alternative
is rewriting master to drop the blob, which forces all 8 collaborators to
re-clone and still leaves the objects on GitHub's servers.

Appended to the existing ignore list rather than replacing it; the two
false-positive pins already there (the docs placeholder and the tracker
test fixture) are untouched.

These 21 differ from those two in kind: the values were real, not false
positives. Pinning them is only honest after rotation, since rotation is
what makes the recorded values dead. The file says so, at length, where
the next person to read it will see it.

Verified against a pristine clone of master with gitleaks 8.21.2 — the
same version CI installs: 21 findings before, "no leaks found" after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Jul 30, 2026
A backup of a local environment file was committed to master in cfb23d2
(#160, 2026-07-28) and has been tracked ever since. It carries live
credentials, so gitleaks has been failing on every PR since that merge.

The old ignore list named each env variant explicitly — .env, .env.local,
.env.development and so on — so a .env.bak-<date> matched none of them.
Replaced with `.env*` plus a negation for the checked-in .env.example,
which fails closed instead of open.

This stops the file being carried forward and closes the gitignore gap.
It does NOT clear the gitleaks check: that job runs `gitleaks detect
--source .`, which scans history, so the findings still resolve to
cfb23d2 regardless of what HEAD looks like. See the PR for the options.

Nor does it undo the exposure — the values are in the repository's
history and need rotating on their own schedule.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant