lurk is a Reddit buyer-intent finder you can self-host for free, hosted at https://lurk.so. It watches the subreddits and keywords your buyers use, scores every post and comment against your product, tells you in one sentence why each one scored what it did, and shows what the data behind that lead cost, down to the request.
Data comes from AnyAPI: one key, pay per request in USD, no subscription. "Premium" here means connecting your own AnyAPI wallet, not paying us a monthly fee. Self-hosting has no limits at all.
- Builds your product profile from your URL. One page read plus one language model call gives you the pain, the solution, who buys, the subreddits they post in and the searches they run. Everything is editable.
- Scans on a schedule. Per keyword and per tracked subreddit, then a title-level triage, then it opens only the threads worth reading, then the comments on the best of those. Cost follows quality, not volume.
- Scores with a written reason. 0-100 from fit, intent and how alive the thread is, plus an intent stage, the phrase that matched, and a seller-side flag for the people who are selling rather than buying.
- Ranks the feed. Filter by window, community and stage. Every card carries the poster, the community, the age, the subreddit's own self-promotion rule, and the cost line.
- Drafts a reply in your voice. Comment or DM, conversation-starter or honest pitch, with a Copy button and nothing else.
- Sends alerts. A digest email, a Slack or Discord post, or your own webhook.
- Finds the Reddit threads Google already ranks for your keywords, with position, thread age and whether a competitor is named in it.
- Watches your competitors on Reddit and says whether each mention was positive, negative or neutral.
- Groups your leads into pain themes, over data you already paid for.
- Answers all of it over a read-only API and MCP, so an agent can triage for you.
Deliberately absent, and not planned:
- No posting. No comment or DM is ever sent for you. Drafts have a Copy button.
- No browser extension.
- No conversation inbox. Once you reply, the conversation belongs to Reddit.
- No feedback loop that rewrites your filters. Marking a lead as not a fit records the reason and shows it in Insights; it does not silently change what you see next.
- No archive of Reddit. Search is the index. We keep 30 days and no more.
- No subscription. There is no plan to buy here, in any tier.
You need Docker, a free Clerk application for sign-in, an AnyAPI key and an OpenRouter key.
git clone <repo> lurk
cd lurk
cp .env.example .env
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" # APP_ENCRYPTION_KEYPut that key in APP_ENCRYPTION_KEY, paste your Clerk, AnyAPI and OpenRouter keys into
.env, then:
docker compose upThe app applies its own migrations on start and serves on http://localhost:3000. Sign up, paste your product's URL, and press Scan now.
To let other people connect their own AnyAPI wallet instead of using your key, register this
instance as an OAuth client once and paste the printed id into ANYAPI_OAUTH_CLIENT_ID:
npm run anyapi:register| Variable | Required | Default | What it does |
|---|---|---|---|
DATABASE_URL |
yes | - | Postgres connection string. Compose sets it for you. |
APP_URL |
yes | http://localhost:3000 |
Public origin. Must match the registered OAuth redirect. |
APP_ENCRYPTION_KEY |
yes | - | 32 random bytes, base64. Encrypts stored AnyAPI refresh tokens. |
SELF_HOSTED |
no | false |
true removes every tier limit. |
RUN_SCHEDULER |
no | false |
true on exactly one process runs the every-minute job tick. |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY |
yes | - | Clerk publishable key. |
CLERK_SECRET_KEY |
yes | - | Clerk secret key. |
ANYAPI_BASE_URL |
no | https://api.getanyapi.com |
AnyAPI gateway. |
ANYAPI_OAUTH_CLIENT_ID |
no | - | Printed by npm run anyapi:register. Needed for wallet connect. |
ANYAPI_HOUSE_API_KEY |
no | - | The key used when a user has not connected a wallet. |
OPENROUTER_API_KEY |
no | - | Pays for scoring, drafting and clustering. Without it nothing is scored. |
OPENROUTER_MODEL |
no | meta/muse-spark-1.3-contributor |
Override the model. |
RESEND_API_KEY |
no | - | Sends the digest email. |
ALERTS_FROM_EMAIL |
no | - | The From address on a digest. Email needs this and the Resend key. |
HOUSE_DATA_CAP_USD_PER_DAY |
no | 25 |
Daily ceiling on data spend from the house key. |
HOUSE_LLM_CAP_USD_PER_DAY |
no | 10 |
Daily ceiling on language model spend. |
A variable set to nothing counts as unset, so an empty line in .env never half-configures a
feature.
A hosted instance has two tiers. Self-hosting is neither: it has no limits at all.
| Setting | Free | Connected wallet |
|---|---|---|
| Projects | 2 | unlimited |
| Keywords per project | 25 | unlimited |
| Tracked subreddits per project | 10 | unlimited |
| Scan cadence | every 6 hours | hourly |
| Comment scan | top 20 scored threads per scan | every thread over your threshold |
| Feed window | 30 days | 30 days |
| Drafts | unlimited | unlimited |
| Alerts | daily digest + 1 webhook | hourly, unlimited webhooks |
| Reddit SEO | 10 keywords, refreshed weekly, no volume | unlimited, refreshed daily, with monthly search volume |
| Competitors | 3 | unlimited |
| Insights | full | full |
| API and MCP | read-only, 1,000 requests a day | read-only, 10,000 requests a day |
Connecting a wallet buys freshness and breadth, not features. Every feature is on in every tier.
Sign in, open Settings, and press Connect AnyAPI wallet. You land on the AnyAPI consent
screen, where you set the spend cap this app may use, and come back connected. This app never
sees or stores an AnyAPI key: it holds a refresh token, encrypted with APP_ENCRYPTION_KEY,
and swaps it for a short-lived access token when it needs to make a call. Disconnect deletes
the token here and revokes it at AnyAPI.
Settings -> Alerts is where new leads land. Add an email digest, a Slack or Discord webhook,
or a generic webhook that receives the same digest as JSON. Email needs RESEND_API_KEY and
ALERTS_FROM_EMAIL; the webhooks need nothing. A digest carries the day's new leads with
their score, reason, community and link, in the same shapes the feed uses, and sends nothing
at all when there is nothing new. The scheduler queues one digest pass an hour and each
channel decides whether its own cadence is due.
Settings -> API mints read-only keys prefixed rl_sk_. Send one as a bearer token:
curl -H "Authorization: Bearer $KEY" http://localhost:3000/api/v1/projectsReading costs nothing, and every response says so with X-Request-Cost-Usd: 0. The schema is
served at /openapi.json and the agent guide, which explains what a
lead is and how to triage one, at /agent-guide.md. The same reads
are available to an agent over MCP at /api/mcp.
Real runs on this codebase against tally.so, on 2026-09-05. Data is what AnyAPI billed; model is what OpenRouter billed.
| Run | AnyAPI requests | Data | Model | What came back |
|---|---|---|---|---|
| First scan, 12 keywords and 5 subreddits | 27 | $0.028 | $0.0067 | 4 leads |
| Reddit SEO refresh, 3 keywords | 27 | $0.028 | - | 24 ranking threads |
| Competitor scan, 3 competitors | 27 | $0.032 | - | mentions with a sentiment each |
| Insights over stored leads | 0 | $0 | $0.0004 | pain themes and communities |
| Daily digest email | 0 | $0 | - | one email |
Two things keep the bill this small. Reddit rows are shared: the same search run inside your cadence window is reused, billed at zero, and the Data usage screen shows fetched against reused. And a scan is a funnel, so full posts and comment threads are only bought for the titles that survived triage.
Reddit posts and comments are public facts, so they are stored once and shared across projects: two people tracking the same keyword pay for one fetch between them. Scores are never shared; the same post can be a 92 for one product and a 12 for another. Shared Reddit rows are deleted 30 days after they were posted, along with the leads pointing at them, which is also the feed window.
Every Reddit and Google call this app makes goes through AnyAPI, using the published TypeScript SDK. That is the point of giving this away: the whole category sells you a subscription and hides what the data costs, and here the cost of a lead is printed on the lead.
- One key reaches Reddit search, subreddit listings, posts, comments, profiles and community details, plus Google search for the Reddit SEO screen.
- You pay per request in USD, from a wallet, with no plan and no minimum.
- Every call returns its own price, which is what the cost lines in this app are made of.
- A free trial key with starter credit and no card is a sign-up away at https://getanyapi.com.
Self-hosting needs none of this. It is how the one hosted instance runs.
scripts/azure-provision.sh creates everything in one resource group and is safe to run
again: every step checks for the resource first. Secrets are never arguments to it. They
live in deploy/env.production, which is gitignored, and the script generates the ones you
should not choose yourself.
mkdir -p deploy
cat > deploy/env.production <<'EOF'
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
ANYAPI_HOUSE_API_KEY=
OPENROUTER_API_KEY=
RESEND_API_KEY=
ALERTS_FROM_EMAIL=
EOF
RESOURCE_GROUP=reddit-leads-prod scripts/azure-provision.sh --dry-run
RESOURCE_GROUP=reddit-leads-prod scripts/azure-provision.shIt writes PG_ADMIN_PASSWORD, APP_ENCRYPTION_KEY, DATABASE_URL and APP_URL back into
that file, and sets the same values as Container App secrets. Nothing else holds them.
What it creates, all in centralus by default and all on the smallest sensible tier:
| Resource | Tier |
|---|---|
| Container registry | Basic |
| Postgres flexible server, plus a database and a firewall rule for Azure services | Burstable Standard_B1ms, 32 GiB, version 17 |
| Container Apps environment | Consumption |
| Container app, one replica, port 3000, external ingress | 0.5 vCPU, 1 GiB |
User-assigned managed identity with AcrPull on the registry |
- |
The app is pinned to exactly one replica because RUN_SCHEDULER=true must run on exactly
one process.
Set DOMAIN and the script prints the records to publish, adds the hostname, asks for a
managed certificate and binds it. If the records are not visible yet it says so and leaves
everything else provisioned, so it is safe to run again once DNS has propagated:
RESOURCE_GROUP=reddit-leads-prod DOMAIN=lurk.so scripts/azure-provision.shAn apex domain such as lurk.so needs an A record to the environment's address, because a
CNAME cannot sit at the root of a zone; a subdomain gets a CNAME to the app instead. The
script picks the right shape and the matching certificate validation method for you.
Azure resolves those records itself to prove you own the name and to issue the certificate, so they must be served as published. Behind a proxying CDN, Cloudflare's orange cloud included, Azure sees the proxy's address and its certificate instead and validation never passes. Set the records to DNS-only until the certificate is issued.
.github/workflows/deploy.yml signs in with OpenID Connect, so no Azure password is stored
on GitHub. scripts/azure-github-oidc.sh creates the app registration, the federated
credential that trusts one repository and branch, and the two role assignments the workflow
needs:
GITHUB_REPO=owner/repo RESOURCE_GROUP=reddit-leads-prod scripts/azure-github-oidc.sh --dry-run
GITHUB_REPO=owner/repo RESOURCE_GROUP=reddit-leads-prod scripts/azure-github-oidc.shThen set these on the repository and remove if: false from the deploy job. That is the
whole remaining step.
| Repository variable | Where it comes from |
|---|---|
AZURE_RESOURCE_GROUP |
the resource group you provisioned |
AZURE_CONTAINER_REGISTRY |
registry name, printed by the provision script |
AZURE_CONTAINER_APP |
container app name, printed by the provision script |
APP_HEALTH_URL |
https://<hostname>/api/health, printed by the provision script |
| Repository secret | Where it comes from |
|---|---|
AZURE_CLIENT_ID |
printed by scripts/azure-github-oidc.sh |
AZURE_TENANT_ID |
printed by scripts/azure-github-oidc.sh |
AZURE_SUBSCRIPTION_ID |
printed by scripts/azure-github-oidc.sh |
A deploy builds the image, pushes it, rolls the container app to a revision named after the
commit, and then polls APP_HEALTH_URL until it answers 200. Migrations are not a separate
job: docker-entrypoint.sh applies them before the server starts, and one replica means
they run once.
Until the repository exists, scripts/deploy-manual.sh does the same from a clean checkout:
RESOURCE_GROUP=reddit-leads-prod scripts/deploy-manual.shIt pins --platform linux/amd64, because an image built on an Apple Silicon Mac without it
pushes fine and then fails to pull with "not found".
npm install
docker compose up -d postgres
cp .env.example .env # fill in the keys, point DATABASE_URL at localhost:5433
npm run db:migrate
npm run devnpm run check runs the typecheck, the linter and the unit tests. Three tests open a real
database and skip unless DATABASE_URL is set. npm run db:generate writes a new migration
after a schema change.
MIT.