teft-leads 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. teft_leads-0.1.0/.claude/settings.local.json +18 -0
  2. teft_leads-0.1.0/.env.example +35 -0
  3. teft_leads-0.1.0/.gitignore +20 -0
  4. teft_leads-0.1.0/.python-version +1 -0
  5. teft_leads-0.1.0/CLAUDE.md +101 -0
  6. teft_leads-0.1.0/CONCEPT.md +132 -0
  7. teft_leads-0.1.0/LICENSE +5 -0
  8. teft_leads-0.1.0/NOTICE +14 -0
  9. teft_leads-0.1.0/PKG-INFO +78 -0
  10. teft_leads-0.1.0/README.md +54 -0
  11. teft_leads-0.1.0/SESSION_BOOTSTRAP.md +55 -0
  12. teft_leads-0.1.0/VALIDATE.md +64 -0
  13. teft_leads-0.1.0/docs/agents/domain.md +51 -0
  14. teft_leads-0.1.0/docs/agents/issue-tracker.md +45 -0
  15. teft_leads-0.1.0/docs/agents/triage-labels.md +15 -0
  16. teft_leads-0.1.0/docs/publishing.md +55 -0
  17. teft_leads-0.1.0/pyproject.toml +60 -0
  18. teft_leads-0.1.0/src/teft/__init__.py +9 -0
  19. teft_leads-0.1.0/src/teft/apify.py +230 -0
  20. teft_leads-0.1.0/src/teft/cli.py +157 -0
  21. teft_leads-0.1.0/src/teft/config.py +135 -0
  22. teft_leads-0.1.0/src/teft/enrich.py +249 -0
  23. teft_leads-0.1.0/src/teft/linkedin_urls.py +113 -0
  24. teft_leads-0.1.0/src/teft/models.py +40 -0
  25. teft_leads-0.1.0/src/teft/rank.py +256 -0
  26. teft_leads-0.1.0/src/teft/search.py +95 -0
  27. teft_leads-0.1.0/src/teft/serve/__init__.py +7 -0
  28. teft_leads-0.1.0/src/teft/serve/app.py +278 -0
  29. teft_leads-0.1.0/src/teft/serve/history.py +188 -0
  30. teft_leads-0.1.0/src/teft/serve/runs.py +402 -0
  31. teft_leads-0.1.0/src/teft/serve/settings.py +216 -0
  32. teft_leads-0.1.0/src/teft/serve/web.py +1143 -0
  33. teft_leads-0.1.0/src/teft/session.py +82 -0
  34. teft_leads-0.1.0/src/teft/source.py +63 -0
  35. teft_leads-0.1.0/src/teft/view.py +396 -0
  36. teft_leads-0.1.0/src/teft/wizard.py +172 -0
  37. teft_leads-0.1.0/tests/fixtures/humla_brief.txt +12 -0
  38. teft_leads-0.1.0/tests/test_apify.py +149 -0
  39. teft_leads-0.1.0/tests/test_config.py +139 -0
  40. teft_leads-0.1.0/tests/test_enrich.py +106 -0
  41. teft_leads-0.1.0/tests/test_linkedin_urls.py +67 -0
  42. teft_leads-0.1.0/tests/test_models.py +39 -0
  43. teft_leads-0.1.0/tests/test_rank.py +156 -0
  44. teft_leads-0.1.0/tests/test_serve.py +58 -0
  45. teft_leads-0.1.0/tests/test_serve_history.py +382 -0
  46. teft_leads-0.1.0/tests/test_serve_results.py +199 -0
  47. teft_leads-0.1.0/tests/test_serve_runs.py +611 -0
  48. teft_leads-0.1.0/tests/test_serve_settings.py +267 -0
  49. teft_leads-0.1.0/tests/test_view.py +76 -0
  50. teft_leads-0.1.0/tests/test_wizard.py +52 -0
  51. teft_leads-0.1.0/uv.lock +568 -0
@@ -0,0 +1,18 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Read(//Users/michaelwilhelmsen/.claude/commands/**)",
5
+ "Read(//Users/michaelwilhelmsen/.claude/skills/**)",
6
+ "Read(//Users/michaelwilhelmsen/.claude/**)",
7
+ "Read(//Users/michaelwilhelmsen/.claude/plugins/**)",
8
+ "Read(//Users/michaelwilhelmsen/.agents/skills/wayfinder/**)",
9
+ "Bash(gh issue *)",
10
+ "Bash(git ls-tree *)",
11
+ "Bash(git ls-remote *)",
12
+ "Bash(gh label *)",
13
+ "Bash(echo \"=== setup-matt-pocock-skills ===\" && cat ~/.claude/skills/setup-matt-pocock-skills/SKILL.md 2>/dev/null | head -60; echo \"=== search for a github tracker doc anywhere in ~/.claude/skills ===\" && grep -rl -i \"wayfinding operations\" ~/.claude/skills 2>/dev/null | head; grep -rl -i \"wayfinder:map\" ~/.claude 2>/dev/null | grep -iE 'tracker|github' | head)",
14
+ "Bash(gh api graphql -f 'query= *)",
15
+ "Bash(gh api *)"
16
+ ]
17
+ }
18
+ }
@@ -0,0 +1,35 @@
1
+ # Copy to `.env` (git-ignored) and fill in. Never commit real values.
2
+
3
+ # --- LLM provider (the `rank` command / M3) ----------------------------------
4
+ # Default provider is Anthropic, model Sonnet 5 (cost-efficient; SKIP-rule calibration
5
+ # re-checked equivalent to Opus on the #4 fixtures). Set the key you use below.
6
+ ANTHROPIC_API_KEY=
7
+ OPENAI_API_KEY=
8
+
9
+ # Optional: switch the LLM. TEFT_LLM = anthropic (default) | openai.
10
+ # TEFT_MODEL overrides the per-provider default model id.
11
+ # anthropic default: claude-sonnet-5 (claude-opus-4-8 for the frontier tier)
12
+ # openai default: gpt-5.6-terra (gpt-5.6-luna cheaper, gpt-5.6-sol frontier)
13
+ # Re-run the fixtures before trusting a new provider — see VALIDATE.md.
14
+ # TEFT_LLM=openai
15
+ # TEFT_MODEL=gpt-5.6-terra
16
+
17
+ # --- Candidate source (the `scrape` step) ------------------------------------
18
+ # TEFT_SOURCE = browser (default) | apify
19
+ # browser: drives YOUR logged-in session (needs LI_AT below); $0, no external service.
20
+ # apify: a no-cookies Apify post-search actor scrapes on their infra — faster, and
21
+ # ban risk is off your account. Needs APIFY_TOKEN (apify.com free tier).
22
+ # TEFT_SOURCE=apify
23
+ # APIFY_TOKEN=
24
+ # TEFT_APIFY_ACTOR=harvestapi/linkedin-post-search # content-search (posts)
25
+ # TEFT_APIFY_PEOPLE_ACTOR=harvestapi/linkedin-profile-search # people-search (by title)
26
+
27
+ # --- Search tuning -----------------------------------------------------------
28
+ # How many candidates to pull per query before ranking (default 25).
29
+ # TEFT_MAX_CANDIDATES=25
30
+
31
+ # --- LinkedIn session (browser source only) ----------------------------------
32
+ # Your LinkedIn `li_at` session cookie — reused so teft drives YOUR logged-in
33
+ # session instead of automating a login. See SESSION_BOOTSTRAP.md for how to grab it.
34
+ # Optional: if the browser profile already holds a valid session you can leave this blank.
35
+ LI_AT=
@@ -0,0 +1,20 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+
11
+ # teft runtime data — NEVER commit session cookies or scraped people
12
+ .teft/
13
+ browser-profile/
14
+ candidates.json
15
+ shortlist-*.md
16
+
17
+ # secrets
18
+ .env
19
+ .env.*
20
+ !.env.example
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,101 @@
1
+ # teft — project guide
2
+
3
+ `teft` is a personal, local-first LinkedIn intent-signal lead-finder. It finds people posting
4
+ buyer-intent signals, runs each through a "why now" prompt (with a load-bearing SKIP rule), and
5
+ returns a ranked shortlist. No SaaS, no hosting. Candidate source is pluggable (`TEFT_SOURCE`):
6
+ `browser` (default — drives Michael's *own* logged-in session via Patchright + `li_at` reuse, $0,
7
+ no external service) or `apify` (a no-cookies Apify post-search actor scrapes on their infra —
8
+ faster, ban risk off the account, opt-in external service). Planning lives in GitHub Issues (map
9
+ #1, build plan #6, impl map #7).
10
+
11
+ ## Commands
12
+
13
+ ```bash
14
+ uv run teft run # guided: config → product/brief/phrases → scrape → rank → view
15
+ uv run teft view # local results dashboard (reads latest ~/.teft/shortlist-*.json)
16
+ uv run teft serve # FastAPI web app: BYOK settings, search/re-rank, results, history
17
+ uv run teft scrape -p "phrase" [-p …] [-t "job title" …] [--discover-only] [--dump] [--emails]
18
+ # -p → content-search (signal leads); -t → people-search (role-fit leads, apify only)
19
+ uv run teft rank --brief brief.txt # candidates.json → why-now → shortlist-<date>.{md,json}
20
+ uv run teft bootstrap # verify the logged-in LinkedIn session
21
+ uv run teft version
22
+ ```
23
+
24
+ Pipeline: `scrape` (content-search → per-candidate enrich → `~/.teft/candidates.json`) → `rank`
25
+ (→ `shortlist-<date>.{md,json}`) → `view`. `teft run` chains all three. The scrape/rank split is
26
+ deliberate: scrape once, re-run/tune `rank` for free.
27
+
28
+ ## Config (env, auto-loaded from `.env` — no manual `source`)
29
+
30
+ - `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` — key for the chosen provider (`rank`).
31
+ - `TEFT_SOURCE` — `browser` (default) | `apify`. `apify` needs `APIFY_TOKEN`; `browser` needs `LI_AT`.
32
+ - `APIFY_TOKEN` — Apify API token (free tier) for `TEFT_SOURCE=apify`.
33
+ - `TEFT_APIFY_ACTOR` — post-search actor id (default `harvestapi/linkedin-post-search`).
34
+ - `TEFT_APIFY_PEOPLE_ACTOR` — people-search actor id (default `harvestapi/linkedin-profile-search`).
35
+ - `LI_AT` — LinkedIn session cookie for `browser` source (or sign in once in the persistent profile).
36
+ - `TEFT_LLM` — `anthropic` (default, `claude-sonnet-5`) | `openai` (`gpt-5.6-terra`).
37
+ - `TEFT_MODEL` — override the model id (e.g. `claude-opus-4-8`, `gpt-5.6-sol`, `gpt-5.6-luna`).
38
+ - `TEFT_HEADLESS=1` — invisible/faster browser (default headful is stealthier).
39
+ - `TEFT_MIN_DELAY` / `TEFT_MAX_DELAY` — pacing between candidates (speed vs ban-safety).
40
+ - `TEFT_MAX_CANDIDATES` — int cap per query (default 25 → `Config.max_candidates_per_query`).
41
+ - `TEFT_DATA_DIR` — data dir (default `~/.teft`).
42
+
43
+ `teft run` prompts for and persists any missing key/cookie to `.env` (git-ignored). Data dir holds
44
+ `candidates.json`, `shortlist-*.{md,json}`, `actions.json` (contacted+notes), `campaign.json`,
45
+ `browser-profile/`.
46
+
47
+ ## Dev
48
+
49
+ ```bash
50
+ uv sync
51
+ uv run pyright # type check (must be clean)
52
+ uv run pytest -q # tests
53
+ uv run ruff check . # lint
54
+ ```
55
+
56
+ Stack: Python 3.12, `uv`, `typer`, `pydantic`, `patchright`, `anthropic`, `openai`, `httpx`. `src/teft/`:
57
+ `config` · `models` · `session` (Patchright) · `search` (M1) · `enrich` (M2) · `apify` (post- +
58
+ profile-search sources) · `source` (browser/apify × content/people dispatch) · `rank` (M3, provider
59
+ seam; **two prompts** — signal why-now for content-search, softer role-fit for people-search, keyed
60
+ on `Candidate.source`) · `view` (stdlib server + dashboard) · `serve/` (FastAPI web app: `app`
61
+ routes · `web` inline UI · `runs` engine · `settings` BYOK · `history` records/compare) · `wizard`
62
+ (`teft run`) · `cli`.
63
+
64
+ ## Gotchas / non-obvious facts
65
+
66
+ - **LinkedIn DOM (verified live):** profile pages have **no `<h1>`/og: tags** — name comes from the
67
+ page `<title>` (`name_from_title` handles both profile and activity title shapes, language-agnostic).
68
+ Activity page is stable: post = `.update-components-text`, headline =
69
+ `.update-components-actor__description` (NOT `__title`, which is the name), date =
70
+ `.update-components-actor__sub-description`, post urn from `[data-urn^="urn:li:activity"]`.
71
+ - **Enrichment is one nav/candidate** (activity page only); email is opt-in (`--emails`, extra nav,
72
+ usually empty for cold leads).
73
+ - **Never use `networkidle` waits** — LinkedIn never idles; wait for the specific selector instead.
74
+ - **SKIP-rule calibration** validated on Opus (#4); re-checked equivalent on **Sonnet 5** (now the
75
+ default) via `tests/fixtures/` on 2026-07-16. Re-run the fixtures before trusting any new provider/
76
+ tier. Prompt is ported verbatim from the `prototype/why-now-prompt` branch.
77
+ - Keep Patchright (stealth, #2) for the `browser` source — don't swap to plain Playwright.
78
+ - **Apify source is defensive/unvalidated:** `candidate_from_post_item` probes many field-name
79
+ variants because actor output schemas differ; the exact mapping + the actor input (`keywords`,
80
+ `maxItems`) are unverified against a live token — tune from a `--dump` (`~/.teft/dump/apify-*.json`).
81
+ - Avatars are initials (LinkedIn photo URLs are session-gated, don't render externally).
82
+ - `teft view` is localhost-only, single-user, no auth — don't expose the port.
83
+ - Pure logic (URL/email/name/title parsing, prompt plumbing, actions) is unit-tested; DOM selectors
84
+ are the thin live layer, tunable from `--dump` captures.
85
+
86
+ ## Agent skills
87
+
88
+ ### Issue tracker
89
+
90
+ GitHub Issues via the `gh` CLI (repo `michaelwilhelmsen/linkedin-lead-finder`); this is also where the
91
+ wayfinder maps live (#1, #6, #7). See `docs/agents/issue-tracker.md`.
92
+
93
+ ### Triage labels
94
+
95
+ The five canonical roles, label strings unchanged (`needs-triage`, `needs-info`, `ready-for-agent`,
96
+ `ready-for-human`, `wontfix`) — distinct from the `wayfinder:*` labels. See `docs/agents/triage-labels.md`.
97
+
98
+ ### Domain docs
99
+
100
+ Single-context: `CONTEXT.md` + `docs/adr/` at the repo root (created lazily by `/domain-modeling`, not
101
+ scaffolded upfront). See `docs/agents/domain.md`.
@@ -0,0 +1,132 @@
1
+ # LinkedIn Lead Finder — Concept Seed
2
+
3
+ > **This document is the seed input for a follow-up wayfinder session in this directory.** It captures the reference product, why we're building our own instead of using theirs, sample output shape, and the open questions wayfinder should help answer. Don't treat this as a spec — treat it as the raw brief.
4
+
5
+ ---
6
+
7
+ ## The core idea (one paragraph)
8
+
9
+ Build a tool that takes a description of a product (what it does, who it's for, the pain it solves) and returns a ranked list of high-intent LinkedIn prospects — not scraped-database contacts, but people whose *recent posts, role changes, and public activity* signal they are actively in-market. Each prospect comes with a fit score and a plain-English "why this person, right now" explanation that a human could actually use as the opening line of an outreach message. The angle is "real signals, not stored databases" — surface intent from live public activity rather than filtering a static contact list on firmographics.
10
+
11
+ ---
12
+
13
+ ## Reference: what SeedOps.co does
14
+
15
+ SeedOps (seedops.co) is the tool the Reddit DM was pitching. It's an early-stage AI lead-gen product with a clear positioning hook that's worth stealing conceptually even if we don't copy the implementation.
16
+
17
+ **Pitch:** "Find and outreach high-intent leads with AI. Real signals, not stored databases."
18
+
19
+ **How it works (from their homepage):**
20
+ - User creates a "campaign" and describes their ideal lead in natural language (e.g. "decision-makers actively frustrated with their current vendor and looking to buy a replacement in the last 30 days")
21
+ - SeedOps runs the campaign as a persistent "signal" — it monitors public activity 24/7 and surfaces matches
22
+ - Each match gets a fit score (0–100%) and a short LLM-generated explanation of *why* this person fits *this* campaign *right now*
23
+ - Results are shown in a table: `# | Prospect | Signal | Signal Date | Fit | Found Date`
24
+ - Export to CSV, then plug into any outreach tool (they don't do automated outreach themselves — their words: "for this early stage, no. The only option is to export them and use any outreach tool")
25
+
26
+ **Signal types they surface** (from their landing page examples):
27
+ - **Buying signal** — "Our lead research is still painfully manual. Looking for a tool that surfaces genuinely high-intent prospects..."
28
+ - **Competitor signal** — "Finally switched off our old CRM. The reporting was a nightmare..."
29
+ - **Hiring signal** — "Scaling the team fast this quarter — six open roles..."
30
+ - **Fundraising signal** — "Thrilled to share we just closed our seed round..."
31
+ - **Investment signal** — "We are already backing some incredible companies... Seed-stage founders, my DMs are open."
32
+ - **Expansion signal** — "We're opening our first office in Dubai..."
33
+
34
+ **Pricing (for reference):**
35
+ - Free: 500 credits one-time, 1 running signal, 30 rows/table
36
+ - Pro: $89/mo, 6,000 credits, 5 running signals, unlimited rows
37
+ - Enterprise: custom
38
+
39
+ **Positioning vs. incumbents** (from their compare table):
40
+ - Apollo & lemlist = stored database + cold leads
41
+ - SeedOps = real-time data + real intent signals + AI-powered leads
42
+
43
+ ---
44
+
45
+ ## Sample output (from the Reddit DM screenshots)
46
+
47
+ The redditor shared a live campaign called **"Meeting Management for Agencies an..."** (truncated in the screenshot) with the subtitle *"Creative and sales agencies looking for meeting workflow tools"* — 35 results, targeting a product called **Humla** (a meeting/notes/workflow product). This is the concrete output shape wayfinder should aim to match or improve on:
48
+
49
+ **Table columns:** `# | Prospect (name + LinkedIn headline) | Signal (link to source post) | Signal Date | Fit % | Found Date`
50
+
51
+ **Actual "Fit" explanations from the screenshots** (verbatim, so wayfinder has a concrete target for the LLM output quality):
52
+
53
+ > **Antony Puttick — Group Creative Director | Drummer | Relentlessly Curious — 85% fit — Jul 14, 2026**
54
+ > "Antony is ideal for Humla because he has spent a year building an automated micro-agency workflow specifically triggered by meeting notes, and this means he is currently in the active stage of optimizing the bridge between client recordings and collaborative project management."
55
+
56
+ > **Miladys Cruz-Fisher — Principal & Creative AI Strategist — 85% fit — Jul 2, 2026**
57
+ > "Miladys is ideal for Humla because she has officially relaunched her agency as an AI strategy firm, and this means she is currently building her new internal stack and client service workflows while facing immediate pressure to implement the very AI tools she advocates for."
58
+
59
+ > **Kyle — 85% fit — Jun 19, 2026**
60
+ > "Kyle is ideal for Humla because he is currently expanding his sales team at an AI company, and this means he has a fresh mandate to establish scalable meeting workflows and ensure client records are collaborative as new hires onboard."
61
+
62
+ > **Gillian — 82% fit — Jul 9, 2026**
63
+ > "Gillian is ideal for Humla because she explicitly calls out a capturing problem where valuable insights are lost in meeting notes and siloed updates, and this means her team lacks the collaborative workflow tools needed to surface and centralize client stories."
64
+
65
+ > **Ian — 82% fit — Jul 14, 2026**
66
+ > "Ian is ideal for Humla because he just transitioned to a Director leadership role focused on coaching and empowering a larger [team]... he now faces a fresh mandate to implement scalable [workflows]..."
67
+
68
+ **What's notable about the output quality:**
69
+ - Each explanation follows a `[Person] is ideal for [Product] because [observed fact], and this means [inferred current need]` structure — the "and this means" bridge is the money shot; it's what turns a post into a buying signal.
70
+ - Fit is expressed as a % with a colored dot, not a category. Feels precise even though it's clearly LLM-scored.
71
+ - Signal date and Found date are separate columns — implying the system stores *when* the intent post happened vs. *when* the tool surfaced it.
72
+ - All fits in the shown sample sit in the 82–85% band. Either the threshold is high or the calibration is loose — worth investigating.
73
+
74
+ ---
75
+
76
+ ## Why build our own instead of using SeedOps
77
+
78
+ This is a three-birds-one-stone move for Michael, not a straight "cheaper clone" play:
79
+
80
+ 1. **Own the tool at Screenpartner.** The agency is chronically fighting for larger projects and haggled-down estimates. A working intent-signal engine plugged into Screenpartner's own outbound (marketing agencies, WordPress-shaped businesses, integrations pain-points) could directly move the needle on landing 1M+ NOK projects. That's the "1-2M NOK annual surplus in 5 years" company goal.
81
+
82
+ 2. **Learn the mechanics.** LLM-driven scoring + signal extraction from unstructured web/LinkedIn data is a stack Michael's weakest in (Python, data pipelines, DevOps). Building this is a legitimate skills investment, not a distraction. AI has "removed the barrier of entry for unfamiliar frameworks" per Michael's own notes.
83
+
84
+ 3. **Potential side-product / recurring revenue.** Fits Michael's stated dream (passive/recurring revenue alongside agency salary). Killer hook is already validated by the reddit DM: the pitch worked well enough on Michael that he wants to build it. The seedops founder even admits their own ICP is "so wide" — meaning there's room for a *narrower*, better-positioned tool (e.g. "for agencies selling into agencies", "for WordPress consultancies", etc.).
85
+
86
+ The trap to avoid: this becomes yet another abandoned side project (woodworking app, quotes app, git-timetrack). Wayfinder should push hard on scope. A weekend-shippable v0 that Michael actually uses for Screenpartner outbound is worth 10× a beautiful spec that never gets built.
87
+
88
+ ---
89
+
90
+ ## Open questions (this is the wayfinder agenda)
91
+
92
+ **Product shape**
93
+ - Is this a personal tool for Screenpartner first, or a SaaS from day one? (The answer changes everything about auth, billing, multi-tenancy.)
94
+ - Lead list only, or automated outreach too? SeedOps punted on outreach — is that a real gap or a smart focus?
95
+ - LinkedIn-only or multi-source (Twitter/X, Reddit, HN, company blogs, hiring pages, press releases)?
96
+
97
+ **Input shape**
98
+ - Freeform natural-language campaign brief (SeedOps' approach) vs. structured form (product name, ICP, pain points, competitor list)? A hybrid?
99
+ - Does the user upload their own product one-pager / website URL and we extract positioning automatically?
100
+
101
+ **Data sources & the LinkedIn problem**
102
+ - LinkedIn actively blocks scraping. What's the legal/practical path? Options include: (a) LinkedIn Sales Navigator API + partner status (hard), (b) third-party providers who resell LinkedIn data (Apollo, Proxycurl, Nubela, RocketReach — but these are "stored database"), (c) user-brings-their-own-LinkedIn-session via a browser extension (grey area but scrappy), (d) non-LinkedIn signals only (Twitter, GitHub, personal blogs, company news).
103
+ - What does "real-time" actually mean in practice? Polling frequency vs. cost.
104
+
105
+ **Scoring & LLM prompt design**
106
+ - The "and this means…" template in SeedOps' output is a specific prompt engineering pattern. Reverse-engineering that prompt is probably 30 minutes of work and gets us 70% of the output quality.
107
+ - How is Fit % computed? LLM self-rating (unreliable), embedding similarity to ICP description (calibratable), or a hybrid?
108
+
109
+ **Enrichment**
110
+ - How much per-lead enrichment do we do (email finding, company data, mutual connections)? Each layer adds cost.
111
+
112
+ **Tech stack**
113
+ - Michael's strengths: WordPress/PHP/ACF, JS, CSS. Intermediate: Python, Next.js, React. Weakest: DevOps.
114
+ - Realistic stack candidates: Next.js + Vercel + Supabase + OpenAI/Anthropic API + a scraping/signal-fetch layer (Apify, Bright Data, or custom). Or, for a "start ugly and ship" v0: a Python script + Postgres + a plain HTML dashboard.
115
+ - Cron/queue for signal-polling: Vercel Cron? Trigger.dev? A tiny VPS with systemd?
116
+
117
+ **Cost model (self-imposed constraint)**
118
+ - What's the per-lead cost ceiling before the economics don't work? SeedOps sells 6,000 credits for $89 — that's ~1.5 cents per credit. If a "credit" = one lead surfaced, that's very tight margin unless data-source costs are negligible.
119
+
120
+ **Time budget (self-imposed constraint)**
121
+ - Michael has 1–2 hours on weekday evenings, 8–11 PM. First useful version should exist within 2–3 weeks of evenings, not months. What's the smallest slice that produces one usable Screenpartner lead?
122
+
123
+ ---
124
+
125
+ ## What wayfinder should NOT do in the next session
126
+
127
+ - Don't spec out architecture in detail.
128
+ - Don't write code.
129
+ - Don't pick a name (`linkedin-lead-finder` is a placeholder directory name — the name comes later).
130
+ - Don't attempt to solve the LinkedIn scraping question definitively; just enumerate the realistic paths.
131
+
132
+ The next session's job is: **narrow the shape.** Personal tool vs. SaaS. LinkedIn-only vs. multi-source. Freeform vs. structured input. Lead list vs. outreach. Get to a one-paragraph "here's the v0 we're building and here's what it does" — then the session after that can plan the build.
@@ -0,0 +1,5 @@
1
+ Copyright (c) 2026 Michael Wilhelmsen / Screenpartner. All rights reserved.
2
+
3
+ No permission is granted to any person to use, copy, modify, or distribute this
4
+ software, in whole or in part, in source or binary form, without the prior
5
+ written consent of the copyright holder.
@@ -0,0 +1,14 @@
1
+ teft
2
+ Copyright 2026 Michael Wilhelmsen
3
+
4
+ This product includes software adapted from:
5
+
6
+ linkedin-mcp-server
7
+ https://github.com/stickerdaniel/linkedin-mcp-server
8
+ Copyright (c) Daniel Sticker and contributors
9
+ Licensed under the Apache License, Version 2.0.
10
+
11
+ Specifically, teft's LinkedIn URL normalization and person-link classification
12
+ (src/teft/linkedin_urls.py) are adapted from that project's
13
+ `linkedin_mcp_server/scraping/link_metadata.py`. A copy of the Apache-2.0
14
+ license is available at http://www.apache.org/licenses/LICENSE-2.0.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: teft-leads
3
+ Version: 0.1.0
4
+ Summary: Local-first LinkedIn intent-signal lead-finder with a local web app — real signals, not stored databases.
5
+ Project-URL: Repository, https://github.com/michaelwilhelmsen/linkedin-lead-finder
6
+ Author-email: Michael Wilhelmsen <michael@screenpartner.no>
7
+ License-Expression: LicenseRef-Proprietary
8
+ License-File: LICENSE
9
+ Classifier: Environment :: Console
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: <3.13,>=3.12
15
+ Requires-Dist: anthropic>=0.40
16
+ Requires-Dist: fastapi>=0.115
17
+ Requires-Dist: httpx>=0.27
18
+ Requires-Dist: openai>=2.0
19
+ Requires-Dist: patchright>=1.40
20
+ Requires-Dist: pydantic>=2.7
21
+ Requires-Dist: typer>=0.12
22
+ Requires-Dist: uvicorn>=0.30
23
+ Description-Content-Type: text/markdown
24
+
25
+ # teft
26
+
27
+ > **teft** *(Norwegian: the instinctive nose for a lead — a scent for who's in-market right now).*
28
+ > Repo slug is still `linkedin-lead-finder`; the tool is named `teft`.
29
+
30
+ A personal, local-first tool that turns a freeform brief into a ranked, explained
31
+ shortlist of high-intent LinkedIn prospects — "real signals, not stored databases."
32
+
33
+ ## For colleagues — try it in 2 minutes
34
+
35
+ Nothing to clone. It runs entirely on your own machine (the web app is `127.0.0.1`-only).
36
+
37
+ 1. **Install uv:** `curl -LsSf https://astral.sh/uv/install.sh | sh` (Windows: see [docs.astral.sh/uv](https://docs.astral.sh/uv))
38
+ 2. **Run:** `uvx teft-leads serve` — your browser opens the app. (PyPI name is `teft-leads`; the command you type once installed is still `teft`.)
39
+ 3. **In the app:** Settings → paste your keys → Search → run it.
40
+
41
+ That's it. The rest of this page is for people building on `teft`.
42
+
43
+ ## About / planning
44
+
45
+ Planned with the **wayfinder** workflow; the plan lives as **GitHub Issues**. Two maps:
46
+
47
+ - [**Planning map (#1)**](../../issues/1) — *complete.* Destination reached: the locked v0 definition.
48
+ - [**Build plan (#6)**](../../issues/6) — stack, LLM, architecture, name, milestones.
49
+ - [**Implementation map (#7)**](../../issues/7) — **active.** v0 as five `wayfinder:task` milestones (M0→M4);
50
+ the **frontier** (open, unblocked, unassigned) is the work takeable now — currently
51
+ [**M0 (#8)**](../../issues/8).
52
+
53
+ See [`CONCEPT.md`](CONCEPT.md) for the original seed brief.
54
+
55
+ ## v0 at a glance
56
+
57
+ Python CLI on your Mac, over your *own* logged-in LinkedIn session (Patchright, `li_at` cookie reuse).
58
+
59
+ **Guided:** `teft run` — one command walks you through config (prompts + saves any missing key/cookie),
60
+ then *product → brief → buyer-intent phrases* (or reuse the last campaign), scrapes, ranks, and opens the
61
+ results UI. Defaults to headless/fast.
62
+
63
+ **Or step by step:**
64
+ - **`teft scrape -p "phrase" …`** — content-search → per-candidate activity/profile → `candidates.json`.
65
+ - **`teft rank --brief brief.txt`** — each candidate → the "why now" prompt, SKIP filter → ranked
66
+ `shortlist-<date>.{md,json}`.
67
+ - **`teft view`** — a local, zero-dependency results UI: lead cards, expandable post, Open profile / Open
68
+ post / copy email, and contacted + notes saved to `~/.teft/actions.json`.
69
+
70
+ No Fit %, no monitoring, no outreach, no external services. `.env` is auto-loaded (no manual `source`).
71
+ Speed/stealth knobs: `TEFT_HEADLESS=1`, `TEFT_MIN_DELAY` / `TEFT_MAX_DELAY`, `TEFT_MAX_CANDIDATES` (cap per query, default 25).
72
+
73
+ ## LLM provider
74
+
75
+ `rank` is provider-pluggable. Default is `claude-opus-4-8` (the tier the "why now" prompt's SKIP-rule
76
+ calibration was validated on). To use GPT-5.6 instead, set `TEFT_LLM=openai` (defaults to `gpt-5.6-terra`;
77
+ override with `TEFT_MODEL`, e.g. `gpt-5.6-sol` / `gpt-5.6-luna`) and `OPENAI_API_KEY`. **The SKIP calibration
78
+ is only proven on Claude — re-run the fixtures on a new provider before trusting it** (see [VALIDATE.md](VALIDATE.md)).
@@ -0,0 +1,54 @@
1
+ # teft
2
+
3
+ > **teft** *(Norwegian: the instinctive nose for a lead — a scent for who's in-market right now).*
4
+ > Repo slug is still `linkedin-lead-finder`; the tool is named `teft`.
5
+
6
+ A personal, local-first tool that turns a freeform brief into a ranked, explained
7
+ shortlist of high-intent LinkedIn prospects — "real signals, not stored databases."
8
+
9
+ ## For colleagues — try it in 2 minutes
10
+
11
+ Nothing to clone. It runs entirely on your own machine (the web app is `127.0.0.1`-only).
12
+
13
+ 1. **Install uv:** `curl -LsSf https://astral.sh/uv/install.sh | sh` (Windows: see [docs.astral.sh/uv](https://docs.astral.sh/uv))
14
+ 2. **Run:** `uvx teft-leads serve` — your browser opens the app. (PyPI name is `teft-leads`; the command you type once installed is still `teft`.)
15
+ 3. **In the app:** Settings → paste your keys → Search → run it.
16
+
17
+ That's it. The rest of this page is for people building on `teft`.
18
+
19
+ ## About / planning
20
+
21
+ Planned with the **wayfinder** workflow; the plan lives as **GitHub Issues**. Two maps:
22
+
23
+ - [**Planning map (#1)**](../../issues/1) — *complete.* Destination reached: the locked v0 definition.
24
+ - [**Build plan (#6)**](../../issues/6) — stack, LLM, architecture, name, milestones.
25
+ - [**Implementation map (#7)**](../../issues/7) — **active.** v0 as five `wayfinder:task` milestones (M0→M4);
26
+ the **frontier** (open, unblocked, unassigned) is the work takeable now — currently
27
+ [**M0 (#8)**](../../issues/8).
28
+
29
+ See [`CONCEPT.md`](CONCEPT.md) for the original seed brief.
30
+
31
+ ## v0 at a glance
32
+
33
+ Python CLI on your Mac, over your *own* logged-in LinkedIn session (Patchright, `li_at` cookie reuse).
34
+
35
+ **Guided:** `teft run` — one command walks you through config (prompts + saves any missing key/cookie),
36
+ then *product → brief → buyer-intent phrases* (or reuse the last campaign), scrapes, ranks, and opens the
37
+ results UI. Defaults to headless/fast.
38
+
39
+ **Or step by step:**
40
+ - **`teft scrape -p "phrase" …`** — content-search → per-candidate activity/profile → `candidates.json`.
41
+ - **`teft rank --brief brief.txt`** — each candidate → the "why now" prompt, SKIP filter → ranked
42
+ `shortlist-<date>.{md,json}`.
43
+ - **`teft view`** — a local, zero-dependency results UI: lead cards, expandable post, Open profile / Open
44
+ post / copy email, and contacted + notes saved to `~/.teft/actions.json`.
45
+
46
+ No Fit %, no monitoring, no outreach, no external services. `.env` is auto-loaded (no manual `source`).
47
+ Speed/stealth knobs: `TEFT_HEADLESS=1`, `TEFT_MIN_DELAY` / `TEFT_MAX_DELAY`, `TEFT_MAX_CANDIDATES` (cap per query, default 25).
48
+
49
+ ## LLM provider
50
+
51
+ `rank` is provider-pluggable. Default is `claude-opus-4-8` (the tier the "why now" prompt's SKIP-rule
52
+ calibration was validated on). To use GPT-5.6 instead, set `TEFT_LLM=openai` (defaults to `gpt-5.6-terra`;
53
+ override with `TEFT_MODEL`, e.g. `gpt-5.6-sol` / `gpt-5.6-luna`) and `OPENAI_API_KEY`. **The SKIP calibration
54
+ is only proven on Claude — re-run the fixtures on a new provider before trusting it** (see [VALIDATE.md](VALIDATE.md)).
@@ -0,0 +1,55 @@
1
+ # M0 — session bootstrap + content-search go/no-go (you run this)
2
+
3
+ The scaffold is built. These last two steps touch your **personal LinkedIn account**
4
+ (cookie + live queries, with real ban risk), so they're yours to run — not automated.
5
+ This is the go/no-go for the whole v0 slice.
6
+
7
+ ## 0. Install (one time)
8
+
9
+ ```bash
10
+ uv sync # installs deps into .venv
11
+ uv run patchright install chrome # downloads the browser Patchright drives
12
+ ```
13
+
14
+ ## 1. Grab your `li_at` cookie
15
+
16
+ 1. Log in to LinkedIn in your normal browser.
17
+ 2. DevTools → Application → Cookies → `https://www.linkedin.com` → copy the **`li_at`** value.
18
+ 3. `cp .env.example .env`, paste it as `LI_AT=...`, add your `ANTHROPIC_API_KEY` (for later, M3).
19
+
20
+ `.env` is git-ignored — never commit it. Treat `li_at` like a password.
21
+
22
+ ## 2. Verify the session
23
+
24
+ ```bash
25
+ set -a && source .env && set +a
26
+ uv run teft bootstrap
27
+ ```
28
+
29
+ Expect `✓ Authenticated — your session works.` A headful Chrome opens as you. If it
30
+ fails, sign in once inside the window; the persistent profile keeps the session so you
31
+ can leave `LI_AT` blank afterward.
32
+
33
+ ## 3. The content-search go/no-go (the load-bearing risk)
34
+
35
+ Manually — before we build M1 — confirm LinkedIn **content search** returns usable
36
+ recent posts from strangers via your session. In the Chrome that `bootstrap` opened, or
37
+ by hand:
38
+
39
+ 1. Visit `https://www.linkedin.com/search/results/content/?keywords=<a Humla buyer-intent phrase>`
40
+ (e.g. *"lost in meeting notes"*, *"client follow-up"*, *"onboarding new AEs"*).
41
+ 2. Eyeball ~10–20 results. For each, can you see: **author**, **post text**, **date**, and
42
+ click through to a profile?
43
+ 3. Try 2–3 phrases. Note roughly how many results feel like real intent vs. noise, and
44
+ whether you hit a search-limit wall.
45
+
46
+ ## 4. Record the decision on the ticket
47
+
48
+ Comment on **[M0 (#8)](../../issues/8)** with one of:
49
+
50
+ - **GO** — content-search returns usable posts. → unblocks M1 (#9); we build `content_search`.
51
+ - **NO-GO** — too gated/thin. → we re-scope the smallest slice to **filtered people-search**
52
+ and update the map (#7) before building further. (This is a fine outcome — it's exactly
53
+ what M0 exists to find out cheaply.)
54
+
55
+ Then close #8. M1 is blocked-by #8, so it stays off the frontier until you do.
@@ -0,0 +1,64 @@
1
+ # Afternoon validation — the batched live session
2
+
3
+ All four milestones' **code** is on `main`. The offline half (M3 rank) is tested and
4
+ its prompt is proven (#4). What's left is the **live half** — validating the two
5
+ LinkedIn-driving steps against your own session — plus one end-to-end judgment.
6
+ Do these in order; each unblocks the next. One session, ~30–45 min.
7
+
8
+ Prereq: `uv sync && uv run patchright install chrome`, and `.env` filled
9
+ (`LI_AT`, `ANTHROPIC_API_KEY`) — see [SESSION_BOOTSTRAP.md](SESSION_BOOTSTRAP.md).
10
+
11
+ ```bash
12
+ set -a && source .env && set +a
13
+ ```
14
+
15
+ ## 1. Session (M0 recap)
16
+ ```bash
17
+ uv run teft bootstrap # expect ✓ Authenticated
18
+ ```
19
+
20
+ ## 2. M1 — content-search discovery → closes #9
21
+ ```bash
22
+ uv run teft scrape --discover-only --dump -p "lost in meeting notes" -p "client follow-up"
23
+ ```
24
+ - Expect a list of `/in/...` candidate URLs.
25
+ - **Good** → comment on #9, close it.
26
+ - **Empty/junk** → open `~/.teft/dump/anchors-*.json`; if the profile links are there
27
+ but under a different structure, the fix is the `main a[href]` scope / scroll in
28
+ `src/teft/search.py`. Paste me a dump and I'll adjust.
29
+
30
+ ## 3. M2 — enrichment → closes #10
31
+ ```bash
32
+ uv run teft scrape --dump -p "lost in meeting notes" -p "client follow-up" # writes ~/.teft/candidates.json
33
+ ```
34
+ - Open `~/.teft/candidates.json`: are `name`, `headline`, `signal_post_text`
35
+ populated? `email`/`company` are best-effort (often empty for cold leads — fine).
36
+ - The **live selectors in `enrich.py` (`_scrape_one`) are guesses.** If fields are
37
+ blank, `~/.teft/dump/profile-*.json` has the raw captures — send me one and I'll
38
+ fix the `_NAME_JS` / `_HEADLINE_JS` / `_ACTIVITY_JS` selectors. This is the step
39
+ most likely to need a tuning round.
40
+
41
+ ## 4. M3 + M4 — rank end-to-end → closes #11, #12 (the v0 done-condition)
42
+ ```bash
43
+ uv run teft rank --brief tests/fixtures/humla_brief.txt # or your own brief file
44
+ ```
45
+ - Reads `~/.teft/candidates.json`, writes `~/.teft/shortlist-<date>.md`.
46
+ - **The v0 bar:** is ≥1 entry send-worthy *unedited*? If yes → close #11 and #12; v0 is shipped.
47
+ - Tune loop is cheap: re-run `rank` freely (no re-scrape); only re-`scrape` for fresh candidates.
48
+
49
+ ## Optional — validate the GPT-5.6 provider before trusting it
50
+
51
+ The SKIP-rule calibration is only proven on Claude (#4). To check GPT-5.6 gives the same
52
+ quality (Sara + Marcus surface, Tom's marathon SKIPs) before using it on real runs:
53
+ ```bash
54
+ cp tests/fixtures/candidates.json ~/.teft/candidates.json
55
+ export OPENAI_API_KEY=...
56
+ TEFT_LLM=openai TEFT_MODEL=gpt-5.6-terra uv run teft rank --brief tests/fixtures/humla_brief.txt
57
+ ```
58
+ Compare against the same run with the default (Claude). If GPT holds calibration, switch for real by
59
+ setting `TEFT_LLM=openai` in `.env`; if it force-fits or over-SKIPs, stay on Claude or try `gpt-5.6-sol`.
60
+
61
+ ## If you'd rather I keep going now
62
+ Reply with a pasted dump file (anchors or profile JSON) and I'll tune selectors
63
+ blind against real DOM — that's the one thing that still needs your data, not your
64
+ presence.
@@ -0,0 +1,51 @@
1
+ # Domain Docs
2
+
3
+ How the engineering skills should consume this repo's domain documentation when exploring the codebase.
4
+
5
+ ## Before exploring, read these
6
+
7
+ - **`CONTEXT.md`** at the repo root, or
8
+ - **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
9
+ - **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
10
+
11
+ If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
12
+
13
+ ## File structure
14
+
15
+ Single-context repo (most repos):
16
+
17
+ ```
18
+ /
19
+ ├── CONTEXT.md
20
+ ├── docs/adr/
21
+ │ ├── 0001-event-sourced-orders.md
22
+ │ └── 0002-postgres-for-write-model.md
23
+ └── src/
24
+ ```
25
+
26
+ Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
27
+
28
+ ```
29
+ /
30
+ ├── CONTEXT-MAP.md
31
+ ├── docs/adr/ ← system-wide decisions
32
+ └── src/
33
+ ├── ordering/
34
+ │ ├── CONTEXT.md
35
+ │ └── docs/adr/ ← context-specific decisions
36
+ └── billing/
37
+ ├── CONTEXT.md
38
+ └── docs/adr/
39
+ ```
40
+
41
+ ## Use the glossary's vocabulary
42
+
43
+ When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
44
+
45
+ If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
46
+
47
+ ## Flag ADR conflicts
48
+
49
+ If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
50
+
51
+ > _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_