Vibecode Dux-Soup
track this build4 phases, 12 steps, beginner friendly0%Mechanically this is a Playwright script that drives your own logged-in Chrome profile, clicks buttons, and waits a random number of seconds. An agent can get a working profile-visitor and connection-request sender running in a session, and a small campaign queue with daily caps by the end of a weekend. What you are actually buying from Dux-Soup is the accumulated caution: throttle curves that do not get your account restricted, selectors that keep working after LinkedIn reshuffles the DOM every few weeks, and a cloud mode so your laptop does not have to stay awake. Note that neither the paid tool nor your script is blessed by LinkedIn, so the ban risk is yours in both cases; you are just choosing who tunes the pacing. Build it if you want ten touches a day and enjoy fixing selectors, buy it if outreach volume is how you eat.
You are building a lean indie version of Dux-Soup. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Dux-Soup · indie build An outreach assistant built around the constraint that you do the sending. It keeps the queue, drafts the message, records the outcome, and enforces the limits that stop follow-up becoming harassment, without ever touching a platform on your behalf. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with a local application and SQLite | a personal outreach record does not need a server | | Import | Manual entry and file import only | no scraping, so no automated collection and no obligations you did not intend | | Drafting | Local model drafting against your own notes | the message should sound like you, from context you actually have | | Limits | Per-contact and per-week caps enforced in the queue | the limits exist to stop follow-up becoming pestering | | Tracking | Outcomes recorded by you, since you sent it | no platform integration means the record is manual and honest | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the local application. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A local model for drafting** · free, needs memory - Why: Drafts are built from your private notes about people, which is not material to send to a service by default. - Get it: Any local runner with a model that follows instructions and writes plainly. - Verify: A draft generates locally from a set of notes - [ ] **What the platforms you use actually permit** · free to read - Why: The build deliberately does no automation, but you should know what the rules are, since the temptation to add it will arrive. - Get it: Read the user agreement of every platform you will send through, particularly the sections on automated access and data extraction. - Verify: A written note states what each platform prohibits - [ ] **Fifty real people you have a reason to contact** · free - Why: Drafting quality and the refusal behaviour are only testable against real contacts where you either do or do not have specific context. - Get it: People you have actually met or have a genuine reason to approach, with your real notes on each. - Verify: Fifty contacts exist, at least fifteen with no specific context beyond a job title - [ ] **Your touch and weekly ceilings** · free - Why: They are constants in the code, and deciding them after using the tool means deciding them when you want more. - Get it: Decide the maximum follow-ups per person, the minimum gap, and the weekly total, from the position of someone receiving them. - Verify: Three numbers are written down with the reasoning ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Any automation of a platform account. - Profile scraping and data extraction. - Volume. - Any claim that automated outreach is made compliant. - Pacing heuristics and safety limits tuned across a lot of accounts, not guessed at by you - Selector maintenance: when LinkedIn changes markup, someone else patches their extension and nobody patches yours - Cloud execution, so campaigns keep running with your laptop shut - Webhook and CRM plumbing into HubSpot, Pipedrive and friends, plus Zapier glue - Campaign reporting, reply detection and sequence branching that actually stops when someone answers If one of those is essential to you, that is the reason to keep paying for Dux-Soup, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Dux-Soup The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a relationship outreach assistant that drafts and tracks but does not act as you to replace Dux-Soup. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The product this is modelled on automates a LinkedIn account: visiting profiles, sending connection requests and messaging, at volumes no person would manage. Two things make that a bad thing to rebuild. It breaks the platform's User Agreement, which prohibits automated access and the use of software to extract data or perform actions on your behalf. Accounts doing this get restricted, and the account at risk is yours. Connection request volume in particular is limited to roughly a hundred per rolling week, and exceeding it is the fastest route to a restriction. Scraping profile data is also processing personal data, with the notification obligations that carries when it was not collected from the person. So this build does not automate the platform. It has no browser automation, no credential storage, and no path that performs an action on a platform as you. What it does is the part that is genuinely useful and entirely yours: keep track of who you meant to contact, draft messages you send yourself, record what happened, and tell you when a sequence has become an imposition rather than a follow-up. If the value you wanted was volume, this will not provide it, and that is the point. ### Stack (fixed, do not substitute) - Runtime: TypeScript with a local application and SQLite · a personal outreach record does not need a server - Import: Manual entry and file import only · no scraping, so no automated collection and no obligations you did not intend - Drafting: Local model drafting against your own notes · the message should sound like you, from context you actually have - Limits: Per-contact and per-week caps enforced in the queue · the limits exist to stop follow-up becoming pestering - Tracking: Outcomes recorded by you, since you sent it · no platform integration means the record is manual and honest ### Data model (create this before Phase 1) - contacts(id, name, org, role, source, added_at, notes) · source is how you actually met them - sequences(id, name, steps_json, max_touches, min_gap_days) - queue(id, contact_id, sequence_id, step, due_on, state, drafted_body, sent_at, skipped_reason) - outcomes(id, queue_id, kind, at, note) · kind is replied, declined, no_response or asked_to_stop - suppressions(contact_id, reason, at) · asked to stop is permanent and enforced before anything is queued - weekly_counts(week_start, sent) · your own volume, visible to you - There is deliberately no credentials table and no session store, because the build never acts on a platform. ### Phase 1 · A list of people you actually know of Build: Manual contact entry and file import, each requiring a source describing how you came across them. No scraping, no browser automation, no credential storage anywhere in the build. Trap: Adding an import path that accepts a scraped export makes the tool a consumer of automated collection even though it does none itself, and the obligations attach to holding the data rather than to gathering it. Require a source per contact and make the field one a person has to answer rather than a dropdown with a default. Done when: A contact cannot be added without a source describing how you came across them. No credential or session storage exists anywhere in the codebase, verified by inspection. File import requires the same source information per row. There is no browser automation dependency, verified in the dependency list. Do not build yet: No sequences, drafting or limits. ### Phase 2 · Sequences with a ceiling Build: Sequences of steps with a maximum number of touches and a minimum gap between them. Cap how many messages the queue will surface per week. Make both caps constants rather than settings. Trap: A configurable touch limit becomes six, then nine, because each individual increase seems reasonable and the person on the other end is not in the room. Make the ceiling a constant, and make the weekly cap one too, so the tool's restraint does not depend on your restraint. Done when: A sequence cannot exceed the maximum touches, enforced in the queue rather than the interface. The minimum gap between touches is enforced. The weekly surfaced count is capped as a constant. Neither cap can be raised at runtime, verified by attempting it. Do not build yet: No drafting or outcomes yet. ### Phase 3 · Drafts that sound like you wrote them Build: Draft from your notes on the contact and the reason you are reaching out, locally. Refuse to draft where there is no specific context. Show the notes used so you can see what the draft was based on. Trap: A drafter that produces a plausible message from a name and a job title generates exactly the outreach everyone deletes, and it does so at whatever volume you allow. Require specific context, and refuse rather than producing a generic message, since the refusal is more useful than the draft. Done when: A contact with no specific notes produces a refusal rather than a generic draft, tested directly. Drafts show which notes they used. Drafting runs locally by default. A draft is editable and the sent version is what gets recorded, not the generated one. Do not build yet: No sending record or suppression yet. ### Phase 4 · You send it, and you say what happened Build: Mark a queued message sent when you have sent it yourself. Record outcomes including asked to stop. Make asked to stop a permanent suppression enforced before anything is queued, not at send time. Trap: Enforcing a suppression at send time means the person still appears in your queue every week, and one distracted click puts you back in their inbox after they asked you not to be. Check suppressions when queueing, so a suppressed contact never surfaces at all. Done when: A suppressed contact never appears in the queue, enforced at queue time and tested. Asked to stop is permanent and cannot be undone by re-import. Outcomes are recorded per message including no response. Marking sent requires your action rather than being inferred. Do not build yet: No reporting yet. ### Phase 5 · Seeing what your outreach is actually doing Build: Report reply rate, decline rate and asked-to-stop rate by sequence. Surface a sequence whose asked-to-stop rate is rising. Export everything and delete a contact completely on request. Trap: Reporting only reply rate makes a sequence that annoys nine people and converts one look like a success, which is how outreach gets worse over time. Report the asked-to-stop rate beside it, with equal prominence, since it is the cost the reply rate is being bought with. Done when: Reply, decline and asked-to-stop rates are reported together with equal prominence. A sequence with a rising asked-to-stop rate is surfaced. A contact can be deleted completely including their queue history. Export contains contacts, sequences, queue and outcomes. Do not build yet: Nothing after this. ### Out of scope (and why) - Any automation of a platform account: no browser control, no credential storage, no actions performed as you. This is the feature the original is built on and its absence is the point. - Profile scraping and data extraction, which breaks the platform terms and creates notification obligations for personal data you did not collect from the person. - Volume. The caps are constants and the tool will not send more than a person reasonably would. - Any claim that this makes automated outreach compliant. It does not automate outreach at all. ### README must contain - That the build performs no platform actions and stores no credentials, deliberately - That touch and weekly caps are constants rather than settings - That a contact with no specific context produces a refusal rather than a generic draft - That asked to stop is a permanent suppression enforced when queueing, not when sending - That the asked-to-stop rate is reported beside the reply rate with equal prominence ===== AGENTS.md ===== # Agent instructions · Dux-Soup indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with a local application and SQLite, Manual entry and file import only, Local model drafting against your own notes, Per-contact and per-week caps enforced in the queue, Outcomes recorded by you, since you sent it. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Adding an import path that accepts a scraped export makes the tool a consumer of automated collection even though it does none itself, and the obligations attach to holding the data rather than to gathering it. Require a source per contact and make the field one a person has to answer rather than a dropdown with a default. - A configurable touch limit becomes six, then nine, because each individual increase seems reasonable and the person on the other end is not in the room. Make the ceiling a constant, and make the weekly cap one too, so the tool's restraint does not depend on your restraint. - A drafter that produces a plausible message from a name and a job title generates exactly the outreach everyone deletes, and it does so at whatever volume you allow. Require specific context, and refuse rather than producing a generic message, since the refusal is more useful than the draft. - Enforcing a suppression at send time means the person still appears in your queue every week, and one distracted click puts you back in their inbox after they asked you not to be. Check suppressions when queueing, so a suppressed contact never surfaces at all. ===== BUILD_PLAN.md ===== # Build plan · Dux-Soup An outreach assistant built around the constraint that you do the sending. It keeps the queue, drafts the message, records the outcome, and enforces the limits that stop follow-up becoming harassment, without ever touching a platform on your behalf. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A list of people you actually know of Contacts arrive by your hand, with the reason you added them. ### Steps 1. Require a source per contact Answered, not defaulted. It is the question that keeps the list honest. Files: `src/contacts.ts` 2. Store no credentials at all There is nothing to store because the build never signs in anywhere. Files: `src/schema.sql` 3. Depend on no automation library Assert it in the dependency check, so it cannot creep back in. Files: `tests/no-automation.test.ts` ### Done when - [ ] A contact requires a source - [ ] No credential storage exists - [ ] Import requires per-row source information - [ ] No browser automation dependency is present ### Watch out - Adding an import path that accepts a scraped export makes the tool a consumer of automated collection even though it does none itself, and the obligations attach to holding the data rather than to gathering it. Require a source per contact and make the field one a person has to answer rather than a dropdown with a default. ## Phase 2 · Sequences with a ceiling A follow-up sequence stops before it becomes pestering. ### Steps 1. Cap touches per contact as a constant Configurable becomes nine. The ceiling should not depend on your mood. Files: `src/limits.ts` 2. Enforce a minimum gap Three messages in a week is not a sequence, it is pestering. Files: `src/queue.ts` 3. Cap the weekly queue Volume is the thing this build declines to provide. Files: `src/limits.ts`, `src/limits.test.ts` ### Done when - [ ] Touch maximum is enforced in the queue - [ ] The minimum gap is enforced - [ ] The weekly count is capped - [ ] Neither cap is raisable at runtime ### Watch out - A configurable touch limit becomes six, then nine, because each individual increase seems reasonable and the person on the other end is not in the room. Make the ceiling a constant, and make the weekly cap one too, so the tool's restraint does not depend on your restraint. ## Phase 3 · Drafts that sound like you wrote them A drafted message is specific enough to be worth sending. ### Steps 1. Refuse without specific context A generic message is worse than none and this is where volume comes from. Files: `src/draft.ts` 2. Show the notes used So you can see whether the draft has anything real in it. Files: `src/draft.ts` 3. Record what you sent, not what was drafted You will edit it. The record should be the truth. Files: `src/queue.ts` ### Done when - [ ] No specific context produces a refusal - [ ] Drafts show the notes used - [ ] Drafting runs locally - [ ] The recorded message is the sent one ### Watch out - A drafter that produces a plausible message from a name and a job title generates exactly the outreach everyone deletes, and it does so at whatever volume you allow. Require specific context, and refuse rather than producing a generic message, since the refusal is more useful than the draft. ## Phase 4 · You send it, and you say what happened The record reflects reality because you entered it. ### Steps 1. Suppress at queue time Not at send. A suppressed contact should never appear. Files: `src/queue.ts`, `tests/suppression.test.ts` 2. Make asked to stop permanent Including across re-import. It is not a preference. Files: `src/suppressions.ts` 3. Require an action to mark sent The record is honest because you made it. Files: `src/queue.ts` ### Done when - [ ] A suppressed contact never enters the queue - [ ] Asked to stop survives a re-import - [ ] Outcomes including no response are recorded - [ ] Marking sent requires your action ### Watch out - Enforcing a suppression at send time means the person still appears in your queue every week, and one distracted click puts you back in their inbox after they asked you not to be. Check suppressions when queueing, so a suppressed contact never surfaces at all. ## Not in this build - Any automation of a platform account. - Profile scraping and data extraction. - Volume. - Any claim that automated outreach is made compliant. ## After v1, if you want it - A reminder to follow up on people who replied, which is where outreach usually fails - Notes captured from a conversation, since the context is what makes the next message worth sending - A view of who you have not spoken to in a year, which is the useful half of a contact list ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Contacts, notes and outcomes. Personal data about identified people. DB_PATH=~/.local/share/outreach/outreach.db # Required. A constant in the queue. Not raisable at runtime. MAX_TOUCHES_PER_CONTACT=3 # Required. Between touches to one person. Three in a week is pestering. MIN_GAP_DAYS=7 # Required. Total messages surfaced per week. Volume is what this build declines to provide. WEEKLY_QUEUE_CAP=20 # Required. Present as a constant so the answer is visible. There is no other value. PLATFORM_AUTOMATION=none # Required. Drafts are built from private notes about people. DRAFT_MODEL=local
You are building a lean indie version of Dux-Soup. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Dux-Soup · indie build An outreach assistant built around the constraint that you do the sending. It keeps the queue, drafts the message, records the outcome, and enforces the limits that stop follow-up becoming harassment, without ever touching a platform on your behalf. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with a local application and SQLite | a personal outreach record does not need a server | | Import | Manual entry and file import only | no scraping, so no automated collection and no obligations you did not intend | | Drafting | Local model drafting against your own notes | the message should sound like you, from context you actually have | | Limits | Per-contact and per-week caps enforced in the queue | the limits exist to stop follow-up becoming pestering | | Tracking | Outcomes recorded by you, since you sent it | no platform integration means the record is manual and honest | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the local application. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A local model for drafting** · free, needs memory - Why: Drafts are built from your private notes about people, which is not material to send to a service by default. - Get it: Any local runner with a model that follows instructions and writes plainly. - Verify: A draft generates locally from a set of notes - [ ] **What the platforms you use actually permit** · free to read - Why: The build deliberately does no automation, but you should know what the rules are, since the temptation to add it will arrive. - Get it: Read the user agreement of every platform you will send through, particularly the sections on automated access and data extraction. - Verify: A written note states what each platform prohibits - [ ] **Fifty real people you have a reason to contact** · free - Why: Drafting quality and the refusal behaviour are only testable against real contacts where you either do or do not have specific context. - Get it: People you have actually met or have a genuine reason to approach, with your real notes on each. - Verify: Fifty contacts exist, at least fifteen with no specific context beyond a job title - [ ] **Your touch and weekly ceilings** · free - Why: They are constants in the code, and deciding them after using the tool means deciding them when you want more. - Get it: Decide the maximum follow-ups per person, the minimum gap, and the weekly total, from the position of someone receiving them. - Verify: Three numbers are written down with the reasoning ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Any automation of a platform account. - Profile scraping and data extraction. - Volume. - Any claim that automated outreach is made compliant. - Pacing heuristics and safety limits tuned across a lot of accounts, not guessed at by you - Selector maintenance: when LinkedIn changes markup, someone else patches their extension and nobody patches yours - Cloud execution, so campaigns keep running with your laptop shut - Webhook and CRM plumbing into HubSpot, Pipedrive and friends, plus Zapier glue - Campaign reporting, reply detection and sequence branching that actually stops when someone answers If one of those is essential to you, that is the reason to keep paying for Dux-Soup, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Dux-Soup The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a relationship outreach assistant that drafts and tracks but does not act as you to replace Dux-Soup. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The product this is modelled on automates a LinkedIn account: visiting profiles, sending connection requests and messaging, at volumes no person would manage. Two things make that a bad thing to rebuild. It breaks the platform's User Agreement, which prohibits automated access and the use of software to extract data or perform actions on your behalf. Accounts doing this get restricted, and the account at risk is yours. Connection request volume in particular is limited to roughly a hundred per rolling week, and exceeding it is the fastest route to a restriction. Scraping profile data is also processing personal data, with the notification obligations that carries when it was not collected from the person. So this build does not automate the platform. It has no browser automation, no credential storage, and no path that performs an action on a platform as you. What it does is the part that is genuinely useful and entirely yours: keep track of who you meant to contact, draft messages you send yourself, record what happened, and tell you when a sequence has become an imposition rather than a follow-up. If the value you wanted was volume, this will not provide it, and that is the point. ### Stack (fixed, do not substitute) - Runtime: TypeScript with a local application and SQLite · a personal outreach record does not need a server - Import: Manual entry and file import only · no scraping, so no automated collection and no obligations you did not intend - Drafting: Local model drafting against your own notes · the message should sound like you, from context you actually have - Limits: Per-contact and per-week caps enforced in the queue · the limits exist to stop follow-up becoming pestering - Tracking: Outcomes recorded by you, since you sent it · no platform integration means the record is manual and honest ### Data model (create this before Phase 1) - contacts(id, name, org, role, source, added_at, notes) · source is how you actually met them - sequences(id, name, steps_json, max_touches, min_gap_days) - queue(id, contact_id, sequence_id, step, due_on, state, drafted_body, sent_at, skipped_reason) - outcomes(id, queue_id, kind, at, note) · kind is replied, declined, no_response or asked_to_stop - suppressions(contact_id, reason, at) · asked to stop is permanent and enforced before anything is queued - weekly_counts(week_start, sent) · your own volume, visible to you - There is deliberately no credentials table and no session store, because the build never acts on a platform. ### Phase 1 · A list of people you actually know of Build: Manual contact entry and file import, each requiring a source describing how you came across them. No scraping, no browser automation, no credential storage anywhere in the build. Trap: Adding an import path that accepts a scraped export makes the tool a consumer of automated collection even though it does none itself, and the obligations attach to holding the data rather than to gathering it. Require a source per contact and make the field one a person has to answer rather than a dropdown with a default. Done when: A contact cannot be added without a source describing how you came across them. No credential or session storage exists anywhere in the codebase, verified by inspection. File import requires the same source information per row. There is no browser automation dependency, verified in the dependency list. Do not build yet: No sequences, drafting or limits. ### Phase 2 · Sequences with a ceiling Build: Sequences of steps with a maximum number of touches and a minimum gap between them. Cap how many messages the queue will surface per week. Make both caps constants rather than settings. Trap: A configurable touch limit becomes six, then nine, because each individual increase seems reasonable and the person on the other end is not in the room. Make the ceiling a constant, and make the weekly cap one too, so the tool's restraint does not depend on your restraint. Done when: A sequence cannot exceed the maximum touches, enforced in the queue rather than the interface. The minimum gap between touches is enforced. The weekly surfaced count is capped as a constant. Neither cap can be raised at runtime, verified by attempting it. Do not build yet: No drafting or outcomes yet. ### Phase 3 · Drafts that sound like you wrote them Build: Draft from your notes on the contact and the reason you are reaching out, locally. Refuse to draft where there is no specific context. Show the notes used so you can see what the draft was based on. Trap: A drafter that produces a plausible message from a name and a job title generates exactly the outreach everyone deletes, and it does so at whatever volume you allow. Require specific context, and refuse rather than producing a generic message, since the refusal is more useful than the draft. Done when: A contact with no specific notes produces a refusal rather than a generic draft, tested directly. Drafts show which notes they used. Drafting runs locally by default. A draft is editable and the sent version is what gets recorded, not the generated one. Do not build yet: No sending record or suppression yet. ### Phase 4 · You send it, and you say what happened Build: Mark a queued message sent when you have sent it yourself. Record outcomes including asked to stop. Make asked to stop a permanent suppression enforced before anything is queued, not at send time. Trap: Enforcing a suppression at send time means the person still appears in your queue every week, and one distracted click puts you back in their inbox after they asked you not to be. Check suppressions when queueing, so a suppressed contact never surfaces at all. Done when: A suppressed contact never appears in the queue, enforced at queue time and tested. Asked to stop is permanent and cannot be undone by re-import. Outcomes are recorded per message including no response. Marking sent requires your action rather than being inferred. Do not build yet: No reporting yet. ### Phase 5 · Seeing what your outreach is actually doing Build: Report reply rate, decline rate and asked-to-stop rate by sequence. Surface a sequence whose asked-to-stop rate is rising. Export everything and delete a contact completely on request. Trap: Reporting only reply rate makes a sequence that annoys nine people and converts one look like a success, which is how outreach gets worse over time. Report the asked-to-stop rate beside it, with equal prominence, since it is the cost the reply rate is being bought with. Done when: Reply, decline and asked-to-stop rates are reported together with equal prominence. A sequence with a rising asked-to-stop rate is surfaced. A contact can be deleted completely including their queue history. Export contains contacts, sequences, queue and outcomes. Do not build yet: Nothing after this. ### Out of scope (and why) - Any automation of a platform account: no browser control, no credential storage, no actions performed as you. This is the feature the original is built on and its absence is the point. - Profile scraping and data extraction, which breaks the platform terms and creates notification obligations for personal data you did not collect from the person. - Volume. The caps are constants and the tool will not send more than a person reasonably would. - Any claim that this makes automated outreach compliant. It does not automate outreach at all. ### README must contain - That the build performs no platform actions and stores no credentials, deliberately - That touch and weekly caps are constants rather than settings - That a contact with no specific context produces a refusal rather than a generic draft - That asked to stop is a permanent suppression enforced when queueing, not when sending - That the asked-to-stop rate is reported beside the reply rate with equal prominence ===== AGENTS.md ===== # Agent instructions · Dux-Soup indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with a local application and SQLite, Manual entry and file import only, Local model drafting against your own notes, Per-contact and per-week caps enforced in the queue, Outcomes recorded by you, since you sent it. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Adding an import path that accepts a scraped export makes the tool a consumer of automated collection even though it does none itself, and the obligations attach to holding the data rather than to gathering it. Require a source per contact and make the field one a person has to answer rather than a dropdown with a default. - A configurable touch limit becomes six, then nine, because each individual increase seems reasonable and the person on the other end is not in the room. Make the ceiling a constant, and make the weekly cap one too, so the tool's restraint does not depend on your restraint. - A drafter that produces a plausible message from a name and a job title generates exactly the outreach everyone deletes, and it does so at whatever volume you allow. Require specific context, and refuse rather than producing a generic message, since the refusal is more useful than the draft. - Enforcing a suppression at send time means the person still appears in your queue every week, and one distracted click puts you back in their inbox after they asked you not to be. Check suppressions when queueing, so a suppressed contact never surfaces at all. ===== BUILD_PLAN.md ===== # Build plan · Dux-Soup An outreach assistant built around the constraint that you do the sending. It keeps the queue, drafts the message, records the outcome, and enforces the limits that stop follow-up becoming harassment, without ever touching a platform on your behalf. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A list of people you actually know of Contacts arrive by your hand, with the reason you added them. ### Steps 1. Require a source per contact Answered, not defaulted. It is the question that keeps the list honest. Files: `src/contacts.ts` 2. Store no credentials at all There is nothing to store because the build never signs in anywhere. Files: `src/schema.sql` 3. Depend on no automation library Assert it in the dependency check, so it cannot creep back in. Files: `tests/no-automation.test.ts` ### Done when - [ ] A contact requires a source - [ ] No credential storage exists - [ ] Import requires per-row source information - [ ] No browser automation dependency is present ### Watch out - Adding an import path that accepts a scraped export makes the tool a consumer of automated collection even though it does none itself, and the obligations attach to holding the data rather than to gathering it. Require a source per contact and make the field one a person has to answer rather than a dropdown with a default. ## Phase 2 · Sequences with a ceiling A follow-up sequence stops before it becomes pestering. ### Steps 1. Cap touches per contact as a constant Configurable becomes nine. The ceiling should not depend on your mood. Files: `src/limits.ts` 2. Enforce a minimum gap Three messages in a week is not a sequence, it is pestering. Files: `src/queue.ts` 3. Cap the weekly queue Volume is the thing this build declines to provide. Files: `src/limits.ts`, `src/limits.test.ts` ### Done when - [ ] Touch maximum is enforced in the queue - [ ] The minimum gap is enforced - [ ] The weekly count is capped - [ ] Neither cap is raisable at runtime ### Watch out - A configurable touch limit becomes six, then nine, because each individual increase seems reasonable and the person on the other end is not in the room. Make the ceiling a constant, and make the weekly cap one too, so the tool's restraint does not depend on your restraint. ## Phase 3 · Drafts that sound like you wrote them A drafted message is specific enough to be worth sending. ### Steps 1. Refuse without specific context A generic message is worse than none and this is where volume comes from. Files: `src/draft.ts` 2. Show the notes used So you can see whether the draft has anything real in it. Files: `src/draft.ts` 3. Record what you sent, not what was drafted You will edit it. The record should be the truth. Files: `src/queue.ts` ### Done when - [ ] No specific context produces a refusal - [ ] Drafts show the notes used - [ ] Drafting runs locally - [ ] The recorded message is the sent one ### Watch out - A drafter that produces a plausible message from a name and a job title generates exactly the outreach everyone deletes, and it does so at whatever volume you allow. Require specific context, and refuse rather than producing a generic message, since the refusal is more useful than the draft. ## Phase 4 · You send it, and you say what happened The record reflects reality because you entered it. ### Steps 1. Suppress at queue time Not at send. A suppressed contact should never appear. Files: `src/queue.ts`, `tests/suppression.test.ts` 2. Make asked to stop permanent Including across re-import. It is not a preference. Files: `src/suppressions.ts` 3. Require an action to mark sent The record is honest because you made it. Files: `src/queue.ts` ### Done when - [ ] A suppressed contact never enters the queue - [ ] Asked to stop survives a re-import - [ ] Outcomes including no response are recorded - [ ] Marking sent requires your action ### Watch out - Enforcing a suppression at send time means the person still appears in your queue every week, and one distracted click puts you back in their inbox after they asked you not to be. Check suppressions when queueing, so a suppressed contact never surfaces at all. ## Not in this build - Any automation of a platform account. - Profile scraping and data extraction. - Volume. - Any claim that automated outreach is made compliant. ## After v1, if you want it - A reminder to follow up on people who replied, which is where outreach usually fails - Notes captured from a conversation, since the context is what makes the next message worth sending - A view of who you have not spoken to in a year, which is the useful half of a contact list ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Contacts, notes and outcomes. Personal data about identified people. DB_PATH=~/.local/share/outreach/outreach.db # Required. A constant in the queue. Not raisable at runtime. MAX_TOUCHES_PER_CONTACT=3 # Required. Between touches to one person. Three in a week is pestering. MIN_GAP_DAYS=7 # Required. Total messages surfaced per week. Volume is what this build declines to provide. WEEKLY_QUEUE_CAP=20 # Required. Present as a constant so the answer is visible. There is no other value. PLATFORM_AUTOMATION=none # Required. Drafts are built from private notes about people. DRAFT_MODEL=local
You are building a production product version of Dux-Soup. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Dux-Soup · product brief ## Problem Mechanically this is a Playwright script that drives your own logged-in Chrome profile, clicks buttons, and waits a random number of seconds. An agent can get a working profile-visitor and connection-request sender running in a session, and a small campaign queue with daily caps by the end of a weekend. What you are actually buying from Dux-Soup is the accumulated caution: throttle curves that do not get your account restricted, selectors that keep working after LinkedIn reshuffles the DOM every few weeks, and a cloud mode so your laptop does not have to stay awake. Note that neither the paid tool nor your script is blessed by LinkedIn, so the ban risk is yours in both cases; you are just choosing who tunes the pacing. Build it if you want ten touches a day and enjoy fixing selectors, buy it if outreach volume is how you eat. ## Product outcome An outreach record that keeps you organised and honest, refuses to write a message with nothing in it, stops before follow-up becomes pestering, and never touches a platform as you. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node 20+ and a local Chrome or Chromium install - A LinkedIn account you are willing to risk, logged in via a persistent browser profile - A CSV of target profile URLs, sourced by you - A machine that stays awake during campaign windows - Tolerance for re-fixing selectors whenever LinkedIn ships a UI change ## Explicit non-goals for v1 - Any automation of a platform account. - Profile scraping and data extraction. - Volume. - Any claim that automated outreach is made compliant. - Pacing heuristics and safety limits tuned across a lot of accounts, not guessed at by you - Selector maintenance: when LinkedIn changes markup, someone else patches their extension and nobody patches yours - Cloud execution, so campaigns keep running with your laptop shut - Webhook and CRM plumbing into HubSpot, Pipedrive and friends, plus Zapier glue - Campaign reporting, reply detection and sequence branching that actually stops when someone answers ## Success criteria - No credential storage is confirmed present - No browser automation dependency is confirmed present - Touch and weekly caps are confirmed not raisable at runtime - A suppressed contact is confirmed never to enter the queue - A contact without context is confirmed to produce a refusal ===== BRIEF.md ===== # Build brief · Dux-Soup The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a relationship outreach assistant that drafts and tracks but does not act as you to replace Dux-Soup. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The product this is modelled on automates a LinkedIn account: visiting profiles, sending connection requests and messaging, at volumes no person would manage. Two things make that a bad thing to rebuild. It breaks the platform's User Agreement, which prohibits automated access and the use of software to extract data or perform actions on your behalf. Accounts doing this get restricted, and the account at risk is yours. Connection request volume in particular is limited to roughly a hundred per rolling week, and exceeding it is the fastest route to a restriction. Scraping profile data is also processing personal data, with the notification obligations that carries when it was not collected from the person. So this build does not automate the platform. It has no browser automation, no credential storage, and no path that performs an action on a platform as you. What it does is the part that is genuinely useful and entirely yours: keep track of who you meant to contact, draft messages you send yourself, record what happened, and tell you when a sequence has become an imposition rather than a follow-up. If the value you wanted was volume, this will not provide it, and that is the point. ### Stack (fixed, do not substitute) - Runtime: TypeScript with a local application and SQLite · a personal outreach record does not need a server - Import: Manual entry and file import only · no scraping, so no automated collection and no obligations you did not intend - Drafting: Local model drafting against your own notes · the message should sound like you, from context you actually have - Limits: Per-contact and per-week caps enforced in the queue · the limits exist to stop follow-up becoming pestering - Tracking: Outcomes recorded by you, since you sent it · no platform integration means the record is manual and honest ### Data model (create this before Phase 1) - contacts(id, name, org, role, source, added_at, notes) · source is how you actually met them - sequences(id, name, steps_json, max_touches, min_gap_days) - queue(id, contact_id, sequence_id, step, due_on, state, drafted_body, sent_at, skipped_reason) - outcomes(id, queue_id, kind, at, note) · kind is replied, declined, no_response or asked_to_stop - suppressions(contact_id, reason, at) · asked to stop is permanent and enforced before anything is queued - weekly_counts(week_start, sent) · your own volume, visible to you - There is deliberately no credentials table and no session store, because the build never acts on a platform. ### Phase 1 · A list of people you actually know of Build: Manual contact entry and file import, each requiring a source describing how you came across them. No scraping, no browser automation, no credential storage anywhere in the build. Trap: Adding an import path that accepts a scraped export makes the tool a consumer of automated collection even though it does none itself, and the obligations attach to holding the data rather than to gathering it. Require a source per contact and make the field one a person has to answer rather than a dropdown with a default. Done when: A contact cannot be added without a source describing how you came across them. No credential or session storage exists anywhere in the codebase, verified by inspection. File import requires the same source information per row. There is no browser automation dependency, verified in the dependency list. Do not build yet: No sequences, drafting or limits. ### Phase 2 · Sequences with a ceiling Build: Sequences of steps with a maximum number of touches and a minimum gap between them. Cap how many messages the queue will surface per week. Make both caps constants rather than settings. Trap: A configurable touch limit becomes six, then nine, because each individual increase seems reasonable and the person on the other end is not in the room. Make the ceiling a constant, and make the weekly cap one too, so the tool's restraint does not depend on your restraint. Done when: A sequence cannot exceed the maximum touches, enforced in the queue rather than the interface. The minimum gap between touches is enforced. The weekly surfaced count is capped as a constant. Neither cap can be raised at runtime, verified by attempting it. Do not build yet: No drafting or outcomes yet. ### Phase 3 · Drafts that sound like you wrote them Build: Draft from your notes on the contact and the reason you are reaching out, locally. Refuse to draft where there is no specific context. Show the notes used so you can see what the draft was based on. Trap: A drafter that produces a plausible message from a name and a job title generates exactly the outreach everyone deletes, and it does so at whatever volume you allow. Require specific context, and refuse rather than producing a generic message, since the refusal is more useful than the draft. Done when: A contact with no specific notes produces a refusal rather than a generic draft, tested directly. Drafts show which notes they used. Drafting runs locally by default. A draft is editable and the sent version is what gets recorded, not the generated one. Do not build yet: No sending record or suppression yet. ### Phase 4 · You send it, and you say what happened Build: Mark a queued message sent when you have sent it yourself. Record outcomes including asked to stop. Make asked to stop a permanent suppression enforced before anything is queued, not at send time. Trap: Enforcing a suppression at send time means the person still appears in your queue every week, and one distracted click puts you back in their inbox after they asked you not to be. Check suppressions when queueing, so a suppressed contact never surfaces at all. Done when: A suppressed contact never appears in the queue, enforced at queue time and tested. Asked to stop is permanent and cannot be undone by re-import. Outcomes are recorded per message including no response. Marking sent requires your action rather than being inferred. Do not build yet: No reporting yet. ### Phase 5 · Seeing what your outreach is actually doing Build: Report reply rate, decline rate and asked-to-stop rate by sequence. Surface a sequence whose asked-to-stop rate is rising. Export everything and delete a contact completely on request. Trap: Reporting only reply rate makes a sequence that annoys nine people and converts one look like a success, which is how outreach gets worse over time. Report the asked-to-stop rate beside it, with equal prominence, since it is the cost the reply rate is being bought with. Done when: Reply, decline and asked-to-stop rates are reported together with equal prominence. A sequence with a rising asked-to-stop rate is surfaced. A contact can be deleted completely including their queue history. Export contains contacts, sequences, queue and outcomes. Do not build yet: Nothing after this. ### Out of scope (and why) - Any automation of a platform account: no browser control, no credential storage, no actions performed as you. This is the feature the original is built on and its absence is the point. - Profile scraping and data extraction, which breaks the platform terms and creates notification obligations for personal data you did not collect from the person. - Volume. The caps are constants and the tool will not send more than a person reasonably would. - Any claim that this makes automated outreach compliant. It does not automate outreach at all. ### README must contain - That the build performs no platform actions and stores no credentials, deliberately - That touch and weekly caps are constants rather than settings - That a contact with no specific context produces a refusal rather than a generic draft - That asked to stop is a permanent suppression enforced when queueing, not when sending - That the asked-to-stop rate is reported beside the reply rate with equal prominence ===== ARCHITECTURE.md ===== # Architecture · Dux-Soup ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with a local application and SQLite | a personal outreach record does not need a server | | Import | Manual entry and file import only | no scraping, so no automated collection and no obligations you did not intend | | Drafting | Local model drafting against your own notes | the message should sound like you, from context you actually have | | Limits | Per-contact and per-week caps enforced in the queue | the limits exist to stop follow-up becoming pestering | | Tracking | Outcomes recorded by you, since you sent it | no platform integration means the record is manual and honest | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Contacts | Manual entry with a required source. | Any store, provided nothing arrives by automated collection. | | Limits | Touch, gap and weekly ceilings as constants. | Not swappable for settings, which drift upward. | | Drafting | Local drafts from real context, or a refusal. | Any model, provided it refuses rather than generating generic text. | | Suppressions | Permanent asked-to-stop enforced at queue time. | Any implementation, provided it prevents surfacing rather than sending. | | Reporting | Cost reported beside benefit. | Any presentation, provided the asked-to-stop rate is equally prominent. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DB_PATH` · required · Contacts, notes and outcomes. Personal data about identified people. - `MAX_TOUCHES_PER_CONTACT` · required · A constant in the queue. Not raisable at runtime. - `MIN_GAP_DAYS` · required · Between touches to one person. Three in a week is pestering. - `WEEKLY_QUEUE_CAP` · required · Total messages surfaced per week. Volume is what this build declines to provide. - `PLATFORM_AUTOMATION` · required · Present as a constant so the answer is visible. There is no other value. - `DRAFT_MODEL` · required · Drafts are built from private notes about people. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Dux-Soup product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with a local application and SQLite, Manual entry and file import only, Local model drafting against your own notes, Per-contact and per-week caps enforced in the queue, Outcomes recorded by you, since you sent it. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Adding an import path that accepts a scraped export makes the tool a consumer of automated collection even though it does none itself, and the obligations attach to holding the data rather than to gathering it. Require a source per contact and make the field one a person has to answer rather than a dropdown with a default. - A configurable touch limit becomes six, then nine, because each individual increase seems reasonable and the person on the other end is not in the room. Make the ceiling a constant, and make the weekly cap one too, so the tool's restraint does not depend on your restraint. - A drafter that produces a plausible message from a name and a job title generates exactly the outreach everyone deletes, and it does so at whatever volume you allow. Require specific context, and refuse rather than producing a generic message, since the refusal is more useful than the draft. - Enforcing a suppression at send time means the person still appears in your queue every week, and one distracted click puts you back in their inbox after they asked you not to be. Check suppressions when queueing, so a suppressed contact never surfaces at all. - Reporting only reply rate makes a sequence that annoys nine people and converts one look like a success, which is how outreach gets worse over time. Report the asked-to-stop rate beside it, with equal prominence, since it is the cost the reply rate is being bought with. ===== MILESTONES.md ===== # Delivery milestones · Dux-Soup Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A list of people you actually know of Contacts arrive by your hand, with the reason you added them. ### Steps 1. Require a source per contact Answered, not defaulted. It is the question that keeps the list honest. Files: `src/contacts.ts` 2. Store no credentials at all There is nothing to store because the build never signs in anywhere. Files: `src/schema.sql` 3. Depend on no automation library Assert it in the dependency check, so it cannot creep back in. Files: `tests/no-automation.test.ts` ### Done when - [ ] A contact requires a source - [ ] No credential storage exists - [ ] Import requires per-row source information - [ ] No browser automation dependency is present ### Watch out - Adding an import path that accepts a scraped export makes the tool a consumer of automated collection even though it does none itself, and the obligations attach to holding the data rather than to gathering it. Require a source per contact and make the field one a person has to answer rather than a dropdown with a default. ## M2 · Sequences with a ceiling A follow-up sequence stops before it becomes pestering. ### Steps 1. Cap touches per contact as a constant Configurable becomes nine. The ceiling should not depend on your mood. Files: `src/limits.ts` 2. Enforce a minimum gap Three messages in a week is not a sequence, it is pestering. Files: `src/queue.ts` 3. Cap the weekly queue Volume is the thing this build declines to provide. Files: `src/limits.ts`, `src/limits.test.ts` ### Done when - [ ] Touch maximum is enforced in the queue - [ ] The minimum gap is enforced - [ ] The weekly count is capped - [ ] Neither cap is raisable at runtime ### Watch out - A configurable touch limit becomes six, then nine, because each individual increase seems reasonable and the person on the other end is not in the room. Make the ceiling a constant, and make the weekly cap one too, so the tool's restraint does not depend on your restraint. ## M3 · Drafts that sound like you wrote them A drafted message is specific enough to be worth sending. ### Steps 1. Refuse without specific context A generic message is worse than none and this is where volume comes from. Files: `src/draft.ts` 2. Show the notes used So you can see whether the draft has anything real in it. Files: `src/draft.ts` 3. Record what you sent, not what was drafted You will edit it. The record should be the truth. Files: `src/queue.ts` ### Done when - [ ] No specific context produces a refusal - [ ] Drafts show the notes used - [ ] Drafting runs locally - [ ] The recorded message is the sent one ### Watch out - A drafter that produces a plausible message from a name and a job title generates exactly the outreach everyone deletes, and it does so at whatever volume you allow. Require specific context, and refuse rather than producing a generic message, since the refusal is more useful than the draft. ## M4 · You send it, and you say what happened The record reflects reality because you entered it. ### Steps 1. Suppress at queue time Not at send. A suppressed contact should never appear. Files: `src/queue.ts`, `tests/suppression.test.ts` 2. Make asked to stop permanent Including across re-import. It is not a preference. Files: `src/suppressions.ts` 3. Require an action to mark sent The record is honest because you made it. Files: `src/queue.ts` ### Done when - [ ] A suppressed contact never enters the queue - [ ] Asked to stop survives a re-import - [ ] Outcomes including no response are recorded - [ ] Marking sent requires your action ### Watch out - Enforcing a suppression at send time means the person still appears in your queue every week, and one distracted click puts you back in their inbox after they asked you not to be. Check suppressions when queueing, so a suppressed contact never surfaces at all. ## M5 · Seeing what your outreach is actually doing (production only) You can tell whether this is working or whether you are just sending things. ### Steps 1. Report the cost beside the benefit Asked-to-stop next to reply rate, same prominence. Files: `src/report.ts` 2. Surface deteriorating sequences A rising complaint rate is the signal that matters. Files: `src/report.ts` 3. Delete a contact completely Including queue history, keeping only the suppression. Files: `src/deletion.ts` ### Done when - [ ] Reply and asked-to-stop rates are equally prominent - [ ] A deteriorating sequence is surfaced - [ ] A contact deletes completely except the suppression - [ ] Export contains contacts, sequences and outcomes ### Watch out - Reporting only reply rate makes a sequence that annoys nine people and converts one look like a success, which is how outreach gets worse over time. Report the asked-to-stop rate beside it, with equal prominence, since it is the cost the reply rate is being bought with. ===== OPERATIONS.md ===== # Operations · Dux-Soup ## Backup Contacts, notes and suppressions are the data. Suppressions matter most, since losing one puts you back in the inbox of someone who asked you not to be there. ## Restore Restore and confirm suppressions survived, and confirm a re-import cannot undo one. That is the property most likely to be lost and most consequential when it is. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Your own weekly volume and the asked-to-stop rate. Both are for you rather than for a system, and both are the numbers people avoid looking at. ## Incident checklist Contacting someone who asked you to stop is the failure that matters and it is the reason suppression is enforced at queue time. If it happens, the suppression was checked too late in the flow. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No credential storage is confirmed present - [ ] No browser automation dependency is confirmed present - [ ] Touch and weekly caps are confirmed not raisable at runtime - [ ] A suppressed contact is confirmed never to enter the queue - [ ] A contact without context is confirmed to produce a refusal ## Launch constraint Do not market omitted Dux-Soup capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Contacts, notes and outcomes. Personal data about identified people. DB_PATH=~/.local/share/outreach/outreach.db # Required. A constant in the queue. Not raisable at runtime. MAX_TOUCHES_PER_CONTACT=3 # Required. Between touches to one person. Three in a week is pestering. MIN_GAP_DAYS=7 # Required. Total messages surfaced per week. Volume is what this build declines to provide. WEEKLY_QUEUE_CAP=20 # Required. Present as a constant so the answer is visible. There is no other value. PLATFORM_AUTOMATION=none # Required. Drafts are built from private notes about people. DRAFT_MODEL=local
# Dux-Soup · indie build An outreach assistant built around the constraint that you do the sending. It keeps the queue, drafts the message, records the outcome, and enforces the limits that stop follow-up becoming harassment, without ever touching a platform on your behalf. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with a local application and SQLite | a personal outreach record does not need a server | | Import | Manual entry and file import only | no scraping, so no automated collection and no obligations you did not intend | | Drafting | Local model drafting against your own notes | the message should sound like you, from context you actually have | | Limits | Per-contact and per-week caps enforced in the queue | the limits exist to stop follow-up becoming pestering | | Tracking | Outcomes recorded by you, since you sent it | no platform integration means the record is manual and honest | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the local application. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A local model for drafting** · free, needs memory - Why: Drafts are built from your private notes about people, which is not material to send to a service by default. - Get it: Any local runner with a model that follows instructions and writes plainly. - Verify: A draft generates locally from a set of notes - [ ] **What the platforms you use actually permit** · free to read - Why: The build deliberately does no automation, but you should know what the rules are, since the temptation to add it will arrive. - Get it: Read the user agreement of every platform you will send through, particularly the sections on automated access and data extraction. - Verify: A written note states what each platform prohibits - [ ] **Fifty real people you have a reason to contact** · free - Why: Drafting quality and the refusal behaviour are only testable against real contacts where you either do or do not have specific context. - Get it: People you have actually met or have a genuine reason to approach, with your real notes on each. - Verify: Fifty contacts exist, at least fifteen with no specific context beyond a job title - [ ] **Your touch and weekly ceilings** · free - Why: They are constants in the code, and deciding them after using the tool means deciding them when you want more. - Get it: Decide the maximum follow-ups per person, the minimum gap, and the weekly total, from the position of someone receiving them. - Verify: Three numbers are written down with the reasoning ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Any automation of a platform account. - Profile scraping and data extraction. - Volume. - Any claim that automated outreach is made compliant. - Pacing heuristics and safety limits tuned across a lot of accounts, not guessed at by you - Selector maintenance: when LinkedIn changes markup, someone else patches their extension and nobody patches yours - Cloud execution, so campaigns keep running with your laptop shut - Webhook and CRM plumbing into HubSpot, Pipedrive and friends, plus Zapier glue - Campaign reporting, reply detection and sequence branching that actually stops when someone answers If one of those is essential to you, that is the reason to keep paying for Dux-Soup, and the README should say so rather than pretend.
# Build brief · Dux-Soup The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a relationship outreach assistant that drafts and tracks but does not act as you to replace Dux-Soup. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The product this is modelled on automates a LinkedIn account: visiting profiles, sending connection requests and messaging, at volumes no person would manage. Two things make that a bad thing to rebuild. It breaks the platform's User Agreement, which prohibits automated access and the use of software to extract data or perform actions on your behalf. Accounts doing this get restricted, and the account at risk is yours. Connection request volume in particular is limited to roughly a hundred per rolling week, and exceeding it is the fastest route to a restriction. Scraping profile data is also processing personal data, with the notification obligations that carries when it was not collected from the person. So this build does not automate the platform. It has no browser automation, no credential storage, and no path that performs an action on a platform as you. What it does is the part that is genuinely useful and entirely yours: keep track of who you meant to contact, draft messages you send yourself, record what happened, and tell you when a sequence has become an imposition rather than a follow-up. If the value you wanted was volume, this will not provide it, and that is the point. ### Stack (fixed, do not substitute) - Runtime: TypeScript with a local application and SQLite · a personal outreach record does not need a server - Import: Manual entry and file import only · no scraping, so no automated collection and no obligations you did not intend - Drafting: Local model drafting against your own notes · the message should sound like you, from context you actually have - Limits: Per-contact and per-week caps enforced in the queue · the limits exist to stop follow-up becoming pestering - Tracking: Outcomes recorded by you, since you sent it · no platform integration means the record is manual and honest ### Data model (create this before Phase 1) - contacts(id, name, org, role, source, added_at, notes) · source is how you actually met them - sequences(id, name, steps_json, max_touches, min_gap_days) - queue(id, contact_id, sequence_id, step, due_on, state, drafted_body, sent_at, skipped_reason) - outcomes(id, queue_id, kind, at, note) · kind is replied, declined, no_response or asked_to_stop - suppressions(contact_id, reason, at) · asked to stop is permanent and enforced before anything is queued - weekly_counts(week_start, sent) · your own volume, visible to you - There is deliberately no credentials table and no session store, because the build never acts on a platform. ### Phase 1 · A list of people you actually know of Build: Manual contact entry and file import, each requiring a source describing how you came across them. No scraping, no browser automation, no credential storage anywhere in the build. Trap: Adding an import path that accepts a scraped export makes the tool a consumer of automated collection even though it does none itself, and the obligations attach to holding the data rather than to gathering it. Require a source per contact and make the field one a person has to answer rather than a dropdown with a default. Done when: A contact cannot be added without a source describing how you came across them. No credential or session storage exists anywhere in the codebase, verified by inspection. File import requires the same source information per row. There is no browser automation dependency, verified in the dependency list. Do not build yet: No sequences, drafting or limits. ### Phase 2 · Sequences with a ceiling Build: Sequences of steps with a maximum number of touches and a minimum gap between them. Cap how many messages the queue will surface per week. Make both caps constants rather than settings. Trap: A configurable touch limit becomes six, then nine, because each individual increase seems reasonable and the person on the other end is not in the room. Make the ceiling a constant, and make the weekly cap one too, so the tool's restraint does not depend on your restraint. Done when: A sequence cannot exceed the maximum touches, enforced in the queue rather than the interface. The minimum gap between touches is enforced. The weekly surfaced count is capped as a constant. Neither cap can be raised at runtime, verified by attempting it. Do not build yet: No drafting or outcomes yet. ### Phase 3 · Drafts that sound like you wrote them Build: Draft from your notes on the contact and the reason you are reaching out, locally. Refuse to draft where there is no specific context. Show the notes used so you can see what the draft was based on. Trap: A drafter that produces a plausible message from a name and a job title generates exactly the outreach everyone deletes, and it does so at whatever volume you allow. Require specific context, and refuse rather than producing a generic message, since the refusal is more useful than the draft. Done when: A contact with no specific notes produces a refusal rather than a generic draft, tested directly. Drafts show which notes they used. Drafting runs locally by default. A draft is editable and the sent version is what gets recorded, not the generated one. Do not build yet: No sending record or suppression yet. ### Phase 4 · You send it, and you say what happened Build: Mark a queued message sent when you have sent it yourself. Record outcomes including asked to stop. Make asked to stop a permanent suppression enforced before anything is queued, not at send time. Trap: Enforcing a suppression at send time means the person still appears in your queue every week, and one distracted click puts you back in their inbox after they asked you not to be. Check suppressions when queueing, so a suppressed contact never surfaces at all. Done when: A suppressed contact never appears in the queue, enforced at queue time and tested. Asked to stop is permanent and cannot be undone by re-import. Outcomes are recorded per message including no response. Marking sent requires your action rather than being inferred. Do not build yet: No reporting yet. ### Phase 5 · Seeing what your outreach is actually doing Build: Report reply rate, decline rate and asked-to-stop rate by sequence. Surface a sequence whose asked-to-stop rate is rising. Export everything and delete a contact completely on request. Trap: Reporting only reply rate makes a sequence that annoys nine people and converts one look like a success, which is how outreach gets worse over time. Report the asked-to-stop rate beside it, with equal prominence, since it is the cost the reply rate is being bought with. Done when: Reply, decline and asked-to-stop rates are reported together with equal prominence. A sequence with a rising asked-to-stop rate is surfaced. A contact can be deleted completely including their queue history. Export contains contacts, sequences, queue and outcomes. Do not build yet: Nothing after this. ### Out of scope (and why) - Any automation of a platform account: no browser control, no credential storage, no actions performed as you. This is the feature the original is built on and its absence is the point. - Profile scraping and data extraction, which breaks the platform terms and creates notification obligations for personal data you did not collect from the person. - Volume. The caps are constants and the tool will not send more than a person reasonably would. - Any claim that this makes automated outreach compliant. It does not automate outreach at all. ### README must contain - That the build performs no platform actions and stores no credentials, deliberately - That touch and weekly caps are constants rather than settings - That a contact with no specific context produces a refusal rather than a generic draft - That asked to stop is a permanent suppression enforced when queueing, not when sending - That the asked-to-stop rate is reported beside the reply rate with equal prominence
# Agent instructions · Dux-Soup indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with a local application and SQLite, Manual entry and file import only, Local model drafting against your own notes, Per-contact and per-week caps enforced in the queue, Outcomes recorded by you, since you sent it. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Adding an import path that accepts a scraped export makes the tool a consumer of automated collection even though it does none itself, and the obligations attach to holding the data rather than to gathering it. Require a source per contact and make the field one a person has to answer rather than a dropdown with a default. - A configurable touch limit becomes six, then nine, because each individual increase seems reasonable and the person on the other end is not in the room. Make the ceiling a constant, and make the weekly cap one too, so the tool's restraint does not depend on your restraint. - A drafter that produces a plausible message from a name and a job title generates exactly the outreach everyone deletes, and it does so at whatever volume you allow. Require specific context, and refuse rather than producing a generic message, since the refusal is more useful than the draft. - Enforcing a suppression at send time means the person still appears in your queue every week, and one distracted click puts you back in their inbox after they asked you not to be. Check suppressions when queueing, so a suppressed contact never surfaces at all.
# Build plan · Dux-Soup An outreach assistant built around the constraint that you do the sending. It keeps the queue, drafts the message, records the outcome, and enforces the limits that stop follow-up becoming harassment, without ever touching a platform on your behalf. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A list of people you actually know of Contacts arrive by your hand, with the reason you added them. ### Steps 1. Require a source per contact Answered, not defaulted. It is the question that keeps the list honest. Files: `src/contacts.ts` 2. Store no credentials at all There is nothing to store because the build never signs in anywhere. Files: `src/schema.sql` 3. Depend on no automation library Assert it in the dependency check, so it cannot creep back in. Files: `tests/no-automation.test.ts` ### Done when - [ ] A contact requires a source - [ ] No credential storage exists - [ ] Import requires per-row source information - [ ] No browser automation dependency is present ### Watch out - Adding an import path that accepts a scraped export makes the tool a consumer of automated collection even though it does none itself, and the obligations attach to holding the data rather than to gathering it. Require a source per contact and make the field one a person has to answer rather than a dropdown with a default. ## Phase 2 · Sequences with a ceiling A follow-up sequence stops before it becomes pestering. ### Steps 1. Cap touches per contact as a constant Configurable becomes nine. The ceiling should not depend on your mood. Files: `src/limits.ts` 2. Enforce a minimum gap Three messages in a week is not a sequence, it is pestering. Files: `src/queue.ts` 3. Cap the weekly queue Volume is the thing this build declines to provide. Files: `src/limits.ts`, `src/limits.test.ts` ### Done when - [ ] Touch maximum is enforced in the queue - [ ] The minimum gap is enforced - [ ] The weekly count is capped - [ ] Neither cap is raisable at runtime ### Watch out - A configurable touch limit becomes six, then nine, because each individual increase seems reasonable and the person on the other end is not in the room. Make the ceiling a constant, and make the weekly cap one too, so the tool's restraint does not depend on your restraint. ## Phase 3 · Drafts that sound like you wrote them A drafted message is specific enough to be worth sending. ### Steps 1. Refuse without specific context A generic message is worse than none and this is where volume comes from. Files: `src/draft.ts` 2. Show the notes used So you can see whether the draft has anything real in it. Files: `src/draft.ts` 3. Record what you sent, not what was drafted You will edit it. The record should be the truth. Files: `src/queue.ts` ### Done when - [ ] No specific context produces a refusal - [ ] Drafts show the notes used - [ ] Drafting runs locally - [ ] The recorded message is the sent one ### Watch out - A drafter that produces a plausible message from a name and a job title generates exactly the outreach everyone deletes, and it does so at whatever volume you allow. Require specific context, and refuse rather than producing a generic message, since the refusal is more useful than the draft. ## Phase 4 · You send it, and you say what happened The record reflects reality because you entered it. ### Steps 1. Suppress at queue time Not at send. A suppressed contact should never appear. Files: `src/queue.ts`, `tests/suppression.test.ts` 2. Make asked to stop permanent Including across re-import. It is not a preference. Files: `src/suppressions.ts` 3. Require an action to mark sent The record is honest because you made it. Files: `src/queue.ts` ### Done when - [ ] A suppressed contact never enters the queue - [ ] Asked to stop survives a re-import - [ ] Outcomes including no response are recorded - [ ] Marking sent requires your action ### Watch out - Enforcing a suppression at send time means the person still appears in your queue every week, and one distracted click puts you back in their inbox after they asked you not to be. Check suppressions when queueing, so a suppressed contact never surfaces at all. ## Not in this build - Any automation of a platform account. - Profile scraping and data extraction. - Volume. - Any claim that automated outreach is made compliant. ## After v1, if you want it - A reminder to follow up on people who replied, which is where outreach usually fails - Notes captured from a conversation, since the context is what makes the next message worth sending - A view of who you have not spoken to in a year, which is the useful half of a contact list
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Contacts, notes and outcomes. Personal data about identified people. DB_PATH=~/.local/share/outreach/outreach.db # Required. A constant in the queue. Not raisable at runtime. MAX_TOUCHES_PER_CONTACT=3 # Required. Between touches to one person. Three in a week is pestering. MIN_GAP_DAYS=7 # Required. Total messages surfaced per week. Volume is what this build declines to provide. WEEKLY_QUEUE_CAP=20 # Required. Present as a constant so the answer is visible. There is no other value. PLATFORM_AUTOMATION=none # Required. Drafts are built from private notes about people. DRAFT_MODEL=local
# Dux-Soup · product brief ## Problem Mechanically this is a Playwright script that drives your own logged-in Chrome profile, clicks buttons, and waits a random number of seconds. An agent can get a working profile-visitor and connection-request sender running in a session, and a small campaign queue with daily caps by the end of a weekend. What you are actually buying from Dux-Soup is the accumulated caution: throttle curves that do not get your account restricted, selectors that keep working after LinkedIn reshuffles the DOM every few weeks, and a cloud mode so your laptop does not have to stay awake. Note that neither the paid tool nor your script is blessed by LinkedIn, so the ban risk is yours in both cases; you are just choosing who tunes the pacing. Build it if you want ten touches a day and enjoy fixing selectors, buy it if outreach volume is how you eat. ## Product outcome An outreach record that keeps you organised and honest, refuses to write a message with nothing in it, stops before follow-up becomes pestering, and never touches a platform as you. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node 20+ and a local Chrome or Chromium install - A LinkedIn account you are willing to risk, logged in via a persistent browser profile - A CSV of target profile URLs, sourced by you - A machine that stays awake during campaign windows - Tolerance for re-fixing selectors whenever LinkedIn ships a UI change ## Explicit non-goals for v1 - Any automation of a platform account. - Profile scraping and data extraction. - Volume. - Any claim that automated outreach is made compliant. - Pacing heuristics and safety limits tuned across a lot of accounts, not guessed at by you - Selector maintenance: when LinkedIn changes markup, someone else patches their extension and nobody patches yours - Cloud execution, so campaigns keep running with your laptop shut - Webhook and CRM plumbing into HubSpot, Pipedrive and friends, plus Zapier glue - Campaign reporting, reply detection and sequence branching that actually stops when someone answers ## Success criteria - No credential storage is confirmed present - No browser automation dependency is confirmed present - Touch and weekly caps are confirmed not raisable at runtime - A suppressed contact is confirmed never to enter the queue - A contact without context is confirmed to produce a refusal
# Build brief · Dux-Soup The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a relationship outreach assistant that drafts and tracks but does not act as you to replace Dux-Soup. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The product this is modelled on automates a LinkedIn account: visiting profiles, sending connection requests and messaging, at volumes no person would manage. Two things make that a bad thing to rebuild. It breaks the platform's User Agreement, which prohibits automated access and the use of software to extract data or perform actions on your behalf. Accounts doing this get restricted, and the account at risk is yours. Connection request volume in particular is limited to roughly a hundred per rolling week, and exceeding it is the fastest route to a restriction. Scraping profile data is also processing personal data, with the notification obligations that carries when it was not collected from the person. So this build does not automate the platform. It has no browser automation, no credential storage, and no path that performs an action on a platform as you. What it does is the part that is genuinely useful and entirely yours: keep track of who you meant to contact, draft messages you send yourself, record what happened, and tell you when a sequence has become an imposition rather than a follow-up. If the value you wanted was volume, this will not provide it, and that is the point. ### Stack (fixed, do not substitute) - Runtime: TypeScript with a local application and SQLite · a personal outreach record does not need a server - Import: Manual entry and file import only · no scraping, so no automated collection and no obligations you did not intend - Drafting: Local model drafting against your own notes · the message should sound like you, from context you actually have - Limits: Per-contact and per-week caps enforced in the queue · the limits exist to stop follow-up becoming pestering - Tracking: Outcomes recorded by you, since you sent it · no platform integration means the record is manual and honest ### Data model (create this before Phase 1) - contacts(id, name, org, role, source, added_at, notes) · source is how you actually met them - sequences(id, name, steps_json, max_touches, min_gap_days) - queue(id, contact_id, sequence_id, step, due_on, state, drafted_body, sent_at, skipped_reason) - outcomes(id, queue_id, kind, at, note) · kind is replied, declined, no_response or asked_to_stop - suppressions(contact_id, reason, at) · asked to stop is permanent and enforced before anything is queued - weekly_counts(week_start, sent) · your own volume, visible to you - There is deliberately no credentials table and no session store, because the build never acts on a platform. ### Phase 1 · A list of people you actually know of Build: Manual contact entry and file import, each requiring a source describing how you came across them. No scraping, no browser automation, no credential storage anywhere in the build. Trap: Adding an import path that accepts a scraped export makes the tool a consumer of automated collection even though it does none itself, and the obligations attach to holding the data rather than to gathering it. Require a source per contact and make the field one a person has to answer rather than a dropdown with a default. Done when: A contact cannot be added without a source describing how you came across them. No credential or session storage exists anywhere in the codebase, verified by inspection. File import requires the same source information per row. There is no browser automation dependency, verified in the dependency list. Do not build yet: No sequences, drafting or limits. ### Phase 2 · Sequences with a ceiling Build: Sequences of steps with a maximum number of touches and a minimum gap between them. Cap how many messages the queue will surface per week. Make both caps constants rather than settings. Trap: A configurable touch limit becomes six, then nine, because each individual increase seems reasonable and the person on the other end is not in the room. Make the ceiling a constant, and make the weekly cap one too, so the tool's restraint does not depend on your restraint. Done when: A sequence cannot exceed the maximum touches, enforced in the queue rather than the interface. The minimum gap between touches is enforced. The weekly surfaced count is capped as a constant. Neither cap can be raised at runtime, verified by attempting it. Do not build yet: No drafting or outcomes yet. ### Phase 3 · Drafts that sound like you wrote them Build: Draft from your notes on the contact and the reason you are reaching out, locally. Refuse to draft where there is no specific context. Show the notes used so you can see what the draft was based on. Trap: A drafter that produces a plausible message from a name and a job title generates exactly the outreach everyone deletes, and it does so at whatever volume you allow. Require specific context, and refuse rather than producing a generic message, since the refusal is more useful than the draft. Done when: A contact with no specific notes produces a refusal rather than a generic draft, tested directly. Drafts show which notes they used. Drafting runs locally by default. A draft is editable and the sent version is what gets recorded, not the generated one. Do not build yet: No sending record or suppression yet. ### Phase 4 · You send it, and you say what happened Build: Mark a queued message sent when you have sent it yourself. Record outcomes including asked to stop. Make asked to stop a permanent suppression enforced before anything is queued, not at send time. Trap: Enforcing a suppression at send time means the person still appears in your queue every week, and one distracted click puts you back in their inbox after they asked you not to be. Check suppressions when queueing, so a suppressed contact never surfaces at all. Done when: A suppressed contact never appears in the queue, enforced at queue time and tested. Asked to stop is permanent and cannot be undone by re-import. Outcomes are recorded per message including no response. Marking sent requires your action rather than being inferred. Do not build yet: No reporting yet. ### Phase 5 · Seeing what your outreach is actually doing Build: Report reply rate, decline rate and asked-to-stop rate by sequence. Surface a sequence whose asked-to-stop rate is rising. Export everything and delete a contact completely on request. Trap: Reporting only reply rate makes a sequence that annoys nine people and converts one look like a success, which is how outreach gets worse over time. Report the asked-to-stop rate beside it, with equal prominence, since it is the cost the reply rate is being bought with. Done when: Reply, decline and asked-to-stop rates are reported together with equal prominence. A sequence with a rising asked-to-stop rate is surfaced. A contact can be deleted completely including their queue history. Export contains contacts, sequences, queue and outcomes. Do not build yet: Nothing after this. ### Out of scope (and why) - Any automation of a platform account: no browser control, no credential storage, no actions performed as you. This is the feature the original is built on and its absence is the point. - Profile scraping and data extraction, which breaks the platform terms and creates notification obligations for personal data you did not collect from the person. - Volume. The caps are constants and the tool will not send more than a person reasonably would. - Any claim that this makes automated outreach compliant. It does not automate outreach at all. ### README must contain - That the build performs no platform actions and stores no credentials, deliberately - That touch and weekly caps are constants rather than settings - That a contact with no specific context produces a refusal rather than a generic draft - That asked to stop is a permanent suppression enforced when queueing, not when sending - That the asked-to-stop rate is reported beside the reply rate with equal prominence
# Architecture · Dux-Soup ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with a local application and SQLite | a personal outreach record does not need a server | | Import | Manual entry and file import only | no scraping, so no automated collection and no obligations you did not intend | | Drafting | Local model drafting against your own notes | the message should sound like you, from context you actually have | | Limits | Per-contact and per-week caps enforced in the queue | the limits exist to stop follow-up becoming pestering | | Tracking | Outcomes recorded by you, since you sent it | no platform integration means the record is manual and honest | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Contacts | Manual entry with a required source. | Any store, provided nothing arrives by automated collection. | | Limits | Touch, gap and weekly ceilings as constants. | Not swappable for settings, which drift upward. | | Drafting | Local drafts from real context, or a refusal. | Any model, provided it refuses rather than generating generic text. | | Suppressions | Permanent asked-to-stop enforced at queue time. | Any implementation, provided it prevents surfacing rather than sending. | | Reporting | Cost reported beside benefit. | Any presentation, provided the asked-to-stop rate is equally prominent. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DB_PATH` · required · Contacts, notes and outcomes. Personal data about identified people. - `MAX_TOUCHES_PER_CONTACT` · required · A constant in the queue. Not raisable at runtime. - `MIN_GAP_DAYS` · required · Between touches to one person. Three in a week is pestering. - `WEEKLY_QUEUE_CAP` · required · Total messages surfaced per week. Volume is what this build declines to provide. - `PLATFORM_AUTOMATION` · required · Present as a constant so the answer is visible. There is no other value. - `DRAFT_MODEL` · required · Drafts are built from private notes about people. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Dux-Soup product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with a local application and SQLite, Manual entry and file import only, Local model drafting against your own notes, Per-contact and per-week caps enforced in the queue, Outcomes recorded by you, since you sent it. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Adding an import path that accepts a scraped export makes the tool a consumer of automated collection even though it does none itself, and the obligations attach to holding the data rather than to gathering it. Require a source per contact and make the field one a person has to answer rather than a dropdown with a default. - A configurable touch limit becomes six, then nine, because each individual increase seems reasonable and the person on the other end is not in the room. Make the ceiling a constant, and make the weekly cap one too, so the tool's restraint does not depend on your restraint. - A drafter that produces a plausible message from a name and a job title generates exactly the outreach everyone deletes, and it does so at whatever volume you allow. Require specific context, and refuse rather than producing a generic message, since the refusal is more useful than the draft. - Enforcing a suppression at send time means the person still appears in your queue every week, and one distracted click puts you back in their inbox after they asked you not to be. Check suppressions when queueing, so a suppressed contact never surfaces at all. - Reporting only reply rate makes a sequence that annoys nine people and converts one look like a success, which is how outreach gets worse over time. Report the asked-to-stop rate beside it, with equal prominence, since it is the cost the reply rate is being bought with.
# Delivery milestones · Dux-Soup Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A list of people you actually know of Contacts arrive by your hand, with the reason you added them. ### Steps 1. Require a source per contact Answered, not defaulted. It is the question that keeps the list honest. Files: `src/contacts.ts` 2. Store no credentials at all There is nothing to store because the build never signs in anywhere. Files: `src/schema.sql` 3. Depend on no automation library Assert it in the dependency check, so it cannot creep back in. Files: `tests/no-automation.test.ts` ### Done when - [ ] A contact requires a source - [ ] No credential storage exists - [ ] Import requires per-row source information - [ ] No browser automation dependency is present ### Watch out - Adding an import path that accepts a scraped export makes the tool a consumer of automated collection even though it does none itself, and the obligations attach to holding the data rather than to gathering it. Require a source per contact and make the field one a person has to answer rather than a dropdown with a default. ## M2 · Sequences with a ceiling A follow-up sequence stops before it becomes pestering. ### Steps 1. Cap touches per contact as a constant Configurable becomes nine. The ceiling should not depend on your mood. Files: `src/limits.ts` 2. Enforce a minimum gap Three messages in a week is not a sequence, it is pestering. Files: `src/queue.ts` 3. Cap the weekly queue Volume is the thing this build declines to provide. Files: `src/limits.ts`, `src/limits.test.ts` ### Done when - [ ] Touch maximum is enforced in the queue - [ ] The minimum gap is enforced - [ ] The weekly count is capped - [ ] Neither cap is raisable at runtime ### Watch out - A configurable touch limit becomes six, then nine, because each individual increase seems reasonable and the person on the other end is not in the room. Make the ceiling a constant, and make the weekly cap one too, so the tool's restraint does not depend on your restraint. ## M3 · Drafts that sound like you wrote them A drafted message is specific enough to be worth sending. ### Steps 1. Refuse without specific context A generic message is worse than none and this is where volume comes from. Files: `src/draft.ts` 2. Show the notes used So you can see whether the draft has anything real in it. Files: `src/draft.ts` 3. Record what you sent, not what was drafted You will edit it. The record should be the truth. Files: `src/queue.ts` ### Done when - [ ] No specific context produces a refusal - [ ] Drafts show the notes used - [ ] Drafting runs locally - [ ] The recorded message is the sent one ### Watch out - A drafter that produces a plausible message from a name and a job title generates exactly the outreach everyone deletes, and it does so at whatever volume you allow. Require specific context, and refuse rather than producing a generic message, since the refusal is more useful than the draft. ## M4 · You send it, and you say what happened The record reflects reality because you entered it. ### Steps 1. Suppress at queue time Not at send. A suppressed contact should never appear. Files: `src/queue.ts`, `tests/suppression.test.ts` 2. Make asked to stop permanent Including across re-import. It is not a preference. Files: `src/suppressions.ts` 3. Require an action to mark sent The record is honest because you made it. Files: `src/queue.ts` ### Done when - [ ] A suppressed contact never enters the queue - [ ] Asked to stop survives a re-import - [ ] Outcomes including no response are recorded - [ ] Marking sent requires your action ### Watch out - Enforcing a suppression at send time means the person still appears in your queue every week, and one distracted click puts you back in their inbox after they asked you not to be. Check suppressions when queueing, so a suppressed contact never surfaces at all. ## M5 · Seeing what your outreach is actually doing (production only) You can tell whether this is working or whether you are just sending things. ### Steps 1. Report the cost beside the benefit Asked-to-stop next to reply rate, same prominence. Files: `src/report.ts` 2. Surface deteriorating sequences A rising complaint rate is the signal that matters. Files: `src/report.ts` 3. Delete a contact completely Including queue history, keeping only the suppression. Files: `src/deletion.ts` ### Done when - [ ] Reply and asked-to-stop rates are equally prominent - [ ] A deteriorating sequence is surfaced - [ ] A contact deletes completely except the suppression - [ ] Export contains contacts, sequences and outcomes ### Watch out - Reporting only reply rate makes a sequence that annoys nine people and converts one look like a success, which is how outreach gets worse over time. Report the asked-to-stop rate beside it, with equal prominence, since it is the cost the reply rate is being bought with.
# Operations · Dux-Soup ## Backup Contacts, notes and suppressions are the data. Suppressions matter most, since losing one puts you back in the inbox of someone who asked you not to be there. ## Restore Restore and confirm suppressions survived, and confirm a re-import cannot undo one. That is the property most likely to be lost and most consequential when it is. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Your own weekly volume and the asked-to-stop rate. Both are for you rather than for a system, and both are the numbers people avoid looking at. ## Incident checklist Contacting someone who asked you to stop is the failure that matters and it is the reason suppression is enforced at queue time. If it happens, the suppression was checked too late in the flow. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No credential storage is confirmed present - [ ] No browser automation dependency is confirmed present - [ ] Touch and weekly caps are confirmed not raisable at runtime - [ ] A suppressed contact is confirmed never to enter the queue - [ ] A contact without context is confirmed to produce a refusal ## Launch constraint Do not market omitted Dux-Soup capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Contacts, notes and outcomes. Personal data about identified people. DB_PATH=~/.local/share/outreach/outreach.db # Required. A constant in the queue. Not raisable at runtime. MAX_TOUCHES_PER_CONTACT=3 # Required. Between touches to one person. Three in a week is pestering. MIN_GAP_DAYS=7 # Required. Total messages surfaced per week. Volume is what this build declines to provide. WEEKLY_QUEUE_CAP=20 # Required. Present as a constant so the answer is visible. There is no other value. PLATFORM_AUTOMATION=none # Required. Drafts are built from private notes about people. DRAFT_MODEL=local
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because the failure modes are expensive and asymmetric. A sloppy script gets your LinkedIn account rate-limited or restricted, and for people doing outbound sales that account is the pipeline. Paying a few tens of dollars a month for someone whose full-time job is keeping the clicks looking human, plus a cloud runner and CRM handoff, is cheaper than a week of debugging selectors and one restricted profile. The DIY build is real and fun for low volume; it is a bad trade at volume.
xPacing heuristics and safety limits tuned across a lot of accounts, not guessed at by you
xSelector maintenance: when LinkedIn changes markup, someone else patches their extension and nobody patches yours
xCloud execution, so campaigns keep running with your laptop shut
xWebhook and CRM plumbing into HubSpot, Pipedrive and friends, plus Zapier glue
xCampaign reporting, reply detection and sequence branching that actually stops when someone answers
Nothing worth pointing at. That's why the prompt exists.
Vibecode Dux-Soup
Kinda. The core of Dux-Soup is buildable in a weekend with the prompt on this page, but there are real gaps: Pacing heuristics and safety limits tuned across a lot of accounts, not guessed at by you, Selector maintenance: when LinkedIn changes markup, someone else patches their extension and nobody patches yours. Read the honest list above before committing.
How much does Dux-Soup cost?
Dux-Soup costs about $14.99/month (Pro Dux, checked 2026-08-18), which is $179.88 per year.
What do I lose by replacing Dux-Soup?
Honestly: Pacing heuristics and safety limits tuned across a lot of accounts, not guessed at by you; Selector maintenance: when LinkedIn changes markup, someone else patches their extension and nobody patches yours; Cloud execution, so campaigns keep running with your laptop shut; Webhook and CRM plumbing into HubSpot, Pipedrive and friends, plus Zapier glue; Campaign reporting, reply detection and sequence branching that actually stops when someone answers. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Dux-Soup?
No mature open-source alternative worth pointing at, which is exactly why the one-shot prompt on this page exists.