Vibecode Sumora
track this build4 phases, 12 steps, beginner friendly0%The visible surface, a prompt box that produces a researched, personalized outreach message, is a one-sitting build. What takes real work is everything that keeps an agent from quietly going insane on step seven: persistent memory about your business and each contact, retries, browser sessions that survive logins and captchas, cost ceilings, and a human approval gate before anything is actually sent. For one person automating their own pipeline, you can get to genuinely useful in a weekend because you are allowed to be the approval step and the error handler. The gap widens fast the moment you want it unattended, multi-user, or connected to a real CRM and mailbox over OAuth. The failure mode also matters: a broken personal script wastes your evening, a broken agent emails the wrong prospect from your domain.
You are building a lean indie version of Sumora. 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 ===== # Sumora · indie build A single-operator outreach agent whose hard problems are treating fetched web text as hostile data rather than instruction, making every claim in a draft traceable to a span of that text, and keeping a multi-step run resumable and bounded in cost when a step fails halfway. Estimated effort: **weekend**. 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 on Node with a small local HTTP server, bound to localhost | one operator reviewing drafts, no accounts and nothing exposed | | Storage | SQLite, with every step written before and after it runs | a crashed run must be resumable, and that means the ledger is the state | | Fetching | A headless browser for the target's own public pages only | the evidence has to come from somewhere you could have read yourself | | Model | One rented model, given only delimited untrusted text and no credentials | the model drafts, it does not act, and that split is the whole safety story | | Sending | A mail credential held by the application, behind an explicit human approval | nothing leaves without somebody reading it, and that somebody is not the model | ## 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 agent, the local review interface and the worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A headless browser automation library with its browser downloaded** · free - Why: Reading a target's own pages needs a real renderer, and this is the only heavy dependency in the build. - Get it: Install the library and run its browser download step. - Verify: A fixture page loads and readable text comes back - [ ] **An API key for a text model** · pay per token; set a provider-side limit too - Why: The drafting step is the only rented part, and the ceilings in this build are what keep it from being the expensive one. - Get it: Create a key with your provider and set a spend limit in their console as well as in this application. - Verify: One run against a local fixture produces a draft with claims - [ ] **Mail sending credentials** (optional) · free tiers are ample at this volume - Why: Required only for live sending, and deliberately absent by default so that dry run is the state you cannot accidentally leave. - Get it: Any transactional sender with a verified domain you control. - Verify: A test message reaches your own inbox with the right sender - [ ] **Who you are allowed to contact** · free - Why: The rules depend on where the recipient is, and retrofitting a contact policy after a campaign is not a thing you can do. - Get it: Write down which jurisdictions you will contact, what basis you rely on, and what source note every target must carry. - Verify: The policy is in the README and every target row has a source and a note ## 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: - Automation of any logged-in site. - CRM and mailbox integrations. - Unattended scheduled sending. - Deliverability and domain warm-up. - Unattended reliability: retries, resumable runs, and recovery when a step fails halfway through - Maintained integrations and OAuth to CRMs, mailboxes, and calendars, plus keeping them working when those APIs shift - Sandboxed execution and guardrails so a confused agent cannot take a real action against a real person - Cost control and model routing, so one runaway loop does not become a surprise bill - Deliverability and sending reputation work, which is its own unglamorous specialty If one of those is essential to you, that is the reason to keep paying for Sumora, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Sumora 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 local research agent that reads a company's own pages, drafts one message with its evidence attached, and cannot send anything you have not read to replace Sumora. 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 Text the agent fetched from the open web is data. It is never instruction. The OWASP Top 10 for large language model applications lists prompt injection as its first entry, and describes indirect prompt injection as the case where content from a website or a file, once interpreted by the model, changes the model's behaviour in ways nobody intended. Its mitigations are the design of this build: privilege control, so the application holds the credentials and the model does not; a human in the loop for privileged operations; and clear separation of untrusted content from the instruction. Concretely, that means the model never holds the mail credential, never chooses a recipient address, and never triggers a send. Fetched page text is stored in its own field, passed in a delimited block labelled as untrusted, and shown to you beside the draft. If a fetched page contains a line telling the agent to email someone else, the worst that can happen is a strange draft you delete. Second, and it is not a technical question: cold outreach to people who did not ask for it is regulated differently in different places, and the rules that apply depend on where the recipient is, not where you are. Decide who you are allowed to contact before you build the sender, record for each address why it was contacted and where it came from, and build the suppression list in Phase 5 before the first live send rather than after the first complaint. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node with a small local HTTP server, bound to localhost · one operator reviewing drafts, no accounts and nothing exposed - Storage: SQLite, with every step written before and after it runs · a crashed run must be resumable, and that means the ledger is the state - Fetching: A headless browser for the target's own public pages only · the evidence has to come from somewhere you could have read yourself - Model: One rented model, given only delimited untrusted text and no credentials · the model drafts, it does not act, and that split is the whole safety story - Sending: A mail credential held by the application, behind an explicit human approval · nothing leaves without somebody reading it, and that somebody is not the model ### Data model (create this before Phase 1) - business_context(id, body, updated_at) · your offer, tone and hard rules, edited by you and never by the model - targets(id, company, domain, contact_name, contact_email, source, source_note, added_at, suppressed_at) - runs(id, target_id, state, created_at, finished_at, cost_minor, failure_reason) · states are queued, running, needs_review, approved, sent, failed - steps(id, run_id, ordinal, tool, input_json, output_json, error, tokens_in, tokens_out, cost_minor, started_at, finished_at) - fetched_pages(id, run_id, url, fetched_at, http_status, content_sha256, text) · stored separately from anything the model is told to follow - claims(id, draft_id, text, page_id, span_start, span_end, verified) · a claim with no span is an unsupported claim - drafts(id, run_id, revision, subject, body, created_at, approved_at, approved_by, sent_at, message_id) - suppression(email_or_domain, reason, added_at) · checked before a run starts and again before a send ### Phase 1 · A run is a ledger, not a loop Build: Define the run as an ordered list of steps persisted in SQLite. Write the step row with its input before execution, complete it with output, cost and timing afterwards. A resume command finds runs left in running, replays from the first incomplete step, and never re-executes a completed one. Trap: Holding the run in memory and writing only the final result means a crash on step five throws away four paid model calls and a browser session, and because the retry looks identical you pay for them again. Persist before you execute, and make resume the normal way a run finishes rather than an error path you test once. Done when: Killing the process during step three and running resume completes the run without re-executing steps one and two, tested. Every step row has a started_at before any side effect and a finished_at or an error afterwards, asserted in the suite. A run can be replayed from its ledger to show exactly what it did and what each step cost. Two resume commands running at once do not both claim the same run, tested with parallel invocations. Do not build yet: No fetching, no model calls, no drafting, no sending. ### Phase 2 · Fetching is quarantine Build: Fetch the target's home page and one about or pricing page with a headless browser. Extract readable text, hash it, store it in fetched_pages. Respect the site's crawl directives and identify yourself in the user agent. Never follow links off the target's own domain. Pass page text to the model only inside a delimited block labelled as untrusted content that the model is told to summarise, not obey. Trap: Concatenating fetched text straight into the prompt means any page can rewrite your agent's instructions, and the injected line does not have to be visible to a human reader to be read by the model. Keep the fetched text in its own field, wrap it in delimiters when you send it, state in the instruction that its contents are data, and show the operator the exact text the model saw. Done when: The instruction and the fetched text are separate arguments in the code and are never string-concatenated, verified by review and by a test on the prompt builder. A fixture page containing an instruction to email a different address produces a draft addressed to the original target, tested. Fetching is limited to the target's own domain, tested with a fixture that links elsewhere. The stored text hash matches what the operator is shown next to the draft. Do not build yet: No drafting, no claims, no cost ceilings, no sending. ### Phase 3 · A draft that can point at its evidence Build: Ask the model for a short message plus a list of claims, each with the exact quoted substring it came from. Verify each quote appears in the stored page text at the offsets given. A claim that fails verification is stripped and the draft is marked unsupported. Show the operator each claim next to its highlighted source. Trap: Asking the model to cite its sources and trusting the citation is the same mistake as trusting the summary, because a fabricated quote is as easy to generate as a fabricated fact. Verify every quoted span against the stored text by string search, and treat a quote that is not found as a failure of the draft rather than a formatting problem to repair. Done when: A claim whose quote does not appear in the stored page text is removed and the draft is flagged, tested with a fabricated citation. Claim verification is a string search against stored text and never a second model call, verified by review. The review screen shows each surviving claim with its source text highlighted. A draft with no verified claims is refused rather than sent for approval. Do not build yet: No ceilings, no approval gate, no sending, no suppression. ### Phase 4 · Ceilings, backoff and giving up loudly Build: Per-run and per-day spend ceilings from the environment, checked before every model and fetch step. Exponential backoff with a small maximum attempt count, then mark the step failed and leave the run resumable. A run that exceeds its ceiling stops in failed with the reason recorded, and the daily ceiling blocks new runs rather than truncating one in progress. Trap: Retrying a model call that returned a malformed response, with no attempt cap, produces a loop that spends money at whatever rate the provider allows and leaves nothing to show for it. Cap attempts, check the ceiling before the call rather than after, and record a failed step rather than retrying past the cap. Done when: A run that would exceed its ceiling stops before making the call that would cross it, tested at the boundary with a stubbed price. Attempts are capped and the cap is asserted in the suite with a fake that always fails. The daily ceiling blocks new runs while allowing an in-progress run to finish, tested. Every failure records a readable reason on the run and appears on the dashboard rather than only in a log. Do not build yet: No approval gate, no sending, no suppression list. ### Phase 5 · The approval gate and the send Build: Dry run is the default and sending requires both a configured credential and an explicit flag. The approve action is a button on the review screen that records who approved and when. Check the suppression list before a run starts and again immediately before the send. Store the message id and keep the full sent body. Provide an unsubscribe route that writes to suppression. Trap: An approve-all control on a list view is the feature that turns a careful design into a mass mailer, because approving twenty drafts by their subject lines is not reading them. Approve one draft at a time from the screen that shows its evidence, and if you want volume, raise the daily run ceiling rather than removing the reading. Done when: No code path sends without a per-draft approval recorded with an actor and a timestamp, asserted in the suite. Sending is impossible without the live flag and a configured credential, tested with both missing. A suppressed address is refused both at run creation and immediately before the send, tested at each point. Every sent message stores its final body, its claims and its message id, and an unsubscribe request adds a suppression row that a later run honours. Do not build yet: Nothing after this. ### Out of scope (and why) - Automating any site that requires a login, which means handling somebody's credentials in a browser the model influences and is the fastest way to turn a drafting tool into an incident. - CRM and mailbox integrations over OAuth, which is a maintenance commitment to somebody else's release schedule rather than a weekend of work. - Unattended sending on a schedule, since the approval gate is the entire safety argument and removing it removes the reason this build is defensible. - Deliverability and domain warm-up, which is a specialty in its own right and cannot be faked by sending more slowly. ### README must contain - That fetched web text is treated as untrusted data and never as instruction, with the mitigation list it follows - That the model holds no credentials, chooses no recipients and triggers no sends - That every claim in a draft is verified against stored page text by string search, and what an unsupported draft looks like - Where the per-run and per-day spend ceilings are set and what happens when they are reached - That the operator is responsible for deciding who may be contacted, and how the suppression list is maintained ===== AGENTS.md ===== # Agent instructions · Sumora indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript on Node with a small local HTTP server, bound to localhost, SQLite, with every step written before and after it runs, A headless browser for the target's own public pages only, One rented model, given only delimited untrusted text and no credentials, A mail credential held by the application, behind an explicit human approval. 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 - Holding the run in memory and writing only the final result means a crash on step five throws away four paid model calls and a browser session, and because the retry looks identical you pay for them again. Persist before you execute, and make resume the normal way a run finishes rather than an error path you test once. - Concatenating fetched text straight into the prompt means any page can rewrite your agent's instructions, and the injected line does not have to be visible to a human reader to be read by the model. Keep the fetched text in its own field, wrap it in delimiters when you send it, state in the instruction that its contents are data, and show the operator the exact text the model saw. - Asking the model to cite its sources and trusting the citation is the same mistake as trusting the summary, because a fabricated quote is as easy to generate as a fabricated fact. Verify every quoted span against the stored text by string search, and treat a quote that is not found as a failure of the draft rather than a formatting problem to repair. - Retrying a model call that returned a malformed response, with no attempt cap, produces a loop that spends money at whatever rate the provider allows and leaves nothing to show for it. Cap attempts, check the ceiling before the call rather than after, and record a failed step rather than retrying past the cap. ===== BUILD_PLAN.md ===== # Build plan · Sumora A single-operator outreach agent whose hard problems are treating fetched web text as hostile data rather than instruction, making every claim in a draft traceable to a span of that text, and keeping a multi-step run resumable and bounded in cost when a step fails halfway. 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 run is a ledger, not a loop Every step is written before it runs and completed after, so a crash resumes instead of restarting. ### Steps 1. Write the step before you execute it The row is the only proof a paid call happened. Files: `src/run/ledger.ts`, `tests/resume.test.ts` 2. Make resume replay from the first incomplete step Restarting from the top is how you pay twice. Files: `src/run/resume.ts` 3. Claim a run before resuming it Two resumes on one run is two outreach emails. Files: `src/run/claim.ts` ### Done when - [ ] A killed run resumes without repeating steps - [ ] Every step has start and end or an error - [ ] A run can be replayed from its ledger - [ ] Concurrent resumes claim distinct runs ### Watch out - Holding the run in memory and writing only the final result means a crash on step five throws away four paid model calls and a browser session, and because the retry looks identical you pay for them again. Persist before you execute, and make resume the normal way a run finishes rather than an error path you test once. ## Phase 2 · Fetching is quarantine Page text is captured, hashed and stored where nothing can mistake it for an instruction. ### Steps 1. Keep fetched text out of the instruction position Concatenation is the vulnerability, not the model. Files: `src/model/prompt.ts`, `tests/injection-fixture.test.ts` 2. Stay on the target's own domain and say who you are The evidence should be pages you could have opened yourself. Files: `src/fetch/browser.ts` 3. Hash and store exactly what the model was given So the operator reviews the same text the model read. Files: `src/fetch/store.ts` ### Done when - [ ] Instruction and page text are never concatenated - [ ] An injected instruction does not change the recipient - [ ] Fetching stays on the target's domain - [ ] Stored hash matches what is displayed ### Watch out - Concatenating fetched text straight into the prompt means any page can rewrite your agent's instructions, and the injected line does not have to be visible to a human reader to be read by the model. Keep the fetched text in its own field, wrap it in delimiters when you send it, state in the instruction that its contents are data, and show the operator the exact text the model saw. ## Phase 3 · A draft that can point at its evidence Every factual claim in the message maps to a span of fetched text, and one that does not blocks the draft. ### Steps 1. Ask for claims with exact quoted spans A quote is checkable; a summary is not. Files: `src/model/draft.ts` 2. Verify quotes by search, never by asking again A second model call verifies nothing. Files: `src/claims/verify.ts`, `tests/verify.test.ts` 3. Highlight the source beside each claim Reviewing is only fast when the evidence is adjacent. Files: `src/web/review.ts` ### Done when - [ ] A fabricated quote is stripped and flagged - [ ] Verification is a string search, not a model call - [ ] Claims display with highlighted sources - [ ] A draft with no verified claims is refused ### Watch out - Asking the model to cite its sources and trusting the citation is the same mistake as trusting the summary, because a fabricated quote is as easy to generate as a fabricated fact. Verify every quoted span against the stored text by string search, and treat a quote that is not found as a failure of the draft rather than a formatting problem to repair. ## Phase 4 · Ceilings, backoff and giving up loudly A confused run costs a known maximum and stops in a state you can see. ### Steps 1. Check the ceiling before the call, not after Afterwards is one call too late, every time. Files: `src/cost/ceiling.ts`, `tests/ceiling.test.ts` 2. Cap attempts and record the failure An uncapped retry is an uncapped bill. Files: `src/run/retry.ts` 3. Put failure reasons on the dashboard A log file is where failures go to be ignored. Files: `src/web/dashboard.ts` ### Done when - [ ] A run stops before crossing its ceiling - [ ] Attempts are capped and tested - [ ] The daily ceiling blocks new runs only - [ ] Failure reasons are visible in the interface ### Watch out - Retrying a model call that returned a malformed response, with no attempt cap, produces a loop that spends money at whatever rate the provider allows and leaves nothing to show for it. Cap attempts, check the ceiling before the call rather than after, and record a failed step rather than retrying past the cap. ## Not in this build - Automation of any logged-in site. - CRM and mailbox integrations. - Unattended scheduled sending. - Deliverability and domain warm-up. ## After v1, if you want it - A second reviewer mode where approval needs two distinct actors, which is what a small team would actually want - A per-target research cache so a repeat run reads nothing it already holds - An export of every sent message with its claims and sources, which is the file you want the day somebody asks ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The drafting model. Give it a provider-side spend limit as well. MODEL_API_KEY=sk-...redacted # Required. Runs, steps, fetched pages, claims and drafts. DATABASE_PATH=./data/agent.db # Required. A single run stops before crossing this figure. RUN_COST_CEILING=0.50 # Required. New runs are blocked at this figure; a run in progress finishes. DAILY_COST_CEILING=5.00 # Required. Per step. Beyond this the step fails and the run stays resumable. MAX_ATTEMPTS=3 # Optional · secret. Absent by default. Sending also needs the live flag. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Required. Dry run is the default and this is the second of the two locks on sending. LIVE_SEND=false
You are building a lean indie version of Sumora. 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 ===== # Sumora · indie build A single-operator outreach agent whose hard problems are treating fetched web text as hostile data rather than instruction, making every claim in a draft traceable to a span of that text, and keeping a multi-step run resumable and bounded in cost when a step fails halfway. Estimated effort: **weekend**. 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 on Node with a small local HTTP server, bound to localhost | one operator reviewing drafts, no accounts and nothing exposed | | Storage | SQLite, with every step written before and after it runs | a crashed run must be resumable, and that means the ledger is the state | | Fetching | A headless browser for the target's own public pages only | the evidence has to come from somewhere you could have read yourself | | Model | One rented model, given only delimited untrusted text and no credentials | the model drafts, it does not act, and that split is the whole safety story | | Sending | A mail credential held by the application, behind an explicit human approval | nothing leaves without somebody reading it, and that somebody is not the model | ## 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 agent, the local review interface and the worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A headless browser automation library with its browser downloaded** · free - Why: Reading a target's own pages needs a real renderer, and this is the only heavy dependency in the build. - Get it: Install the library and run its browser download step. - Verify: A fixture page loads and readable text comes back - [ ] **An API key for a text model** · pay per token; set a provider-side limit too - Why: The drafting step is the only rented part, and the ceilings in this build are what keep it from being the expensive one. - Get it: Create a key with your provider and set a spend limit in their console as well as in this application. - Verify: One run against a local fixture produces a draft with claims - [ ] **Mail sending credentials** (optional) · free tiers are ample at this volume - Why: Required only for live sending, and deliberately absent by default so that dry run is the state you cannot accidentally leave. - Get it: Any transactional sender with a verified domain you control. - Verify: A test message reaches your own inbox with the right sender - [ ] **Who you are allowed to contact** · free - Why: The rules depend on where the recipient is, and retrofitting a contact policy after a campaign is not a thing you can do. - Get it: Write down which jurisdictions you will contact, what basis you rely on, and what source note every target must carry. - Verify: The policy is in the README and every target row has a source and a note ## 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: - Automation of any logged-in site. - CRM and mailbox integrations. - Unattended scheduled sending. - Deliverability and domain warm-up. - Unattended reliability: retries, resumable runs, and recovery when a step fails halfway through - Maintained integrations and OAuth to CRMs, mailboxes, and calendars, plus keeping them working when those APIs shift - Sandboxed execution and guardrails so a confused agent cannot take a real action against a real person - Cost control and model routing, so one runaway loop does not become a surprise bill - Deliverability and sending reputation work, which is its own unglamorous specialty If one of those is essential to you, that is the reason to keep paying for Sumora, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Sumora 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 local research agent that reads a company's own pages, drafts one message with its evidence attached, and cannot send anything you have not read to replace Sumora. 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 Text the agent fetched from the open web is data. It is never instruction. The OWASP Top 10 for large language model applications lists prompt injection as its first entry, and describes indirect prompt injection as the case where content from a website or a file, once interpreted by the model, changes the model's behaviour in ways nobody intended. Its mitigations are the design of this build: privilege control, so the application holds the credentials and the model does not; a human in the loop for privileged operations; and clear separation of untrusted content from the instruction. Concretely, that means the model never holds the mail credential, never chooses a recipient address, and never triggers a send. Fetched page text is stored in its own field, passed in a delimited block labelled as untrusted, and shown to you beside the draft. If a fetched page contains a line telling the agent to email someone else, the worst that can happen is a strange draft you delete. Second, and it is not a technical question: cold outreach to people who did not ask for it is regulated differently in different places, and the rules that apply depend on where the recipient is, not where you are. Decide who you are allowed to contact before you build the sender, record for each address why it was contacted and where it came from, and build the suppression list in Phase 5 before the first live send rather than after the first complaint. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node with a small local HTTP server, bound to localhost · one operator reviewing drafts, no accounts and nothing exposed - Storage: SQLite, with every step written before and after it runs · a crashed run must be resumable, and that means the ledger is the state - Fetching: A headless browser for the target's own public pages only · the evidence has to come from somewhere you could have read yourself - Model: One rented model, given only delimited untrusted text and no credentials · the model drafts, it does not act, and that split is the whole safety story - Sending: A mail credential held by the application, behind an explicit human approval · nothing leaves without somebody reading it, and that somebody is not the model ### Data model (create this before Phase 1) - business_context(id, body, updated_at) · your offer, tone and hard rules, edited by you and never by the model - targets(id, company, domain, contact_name, contact_email, source, source_note, added_at, suppressed_at) - runs(id, target_id, state, created_at, finished_at, cost_minor, failure_reason) · states are queued, running, needs_review, approved, sent, failed - steps(id, run_id, ordinal, tool, input_json, output_json, error, tokens_in, tokens_out, cost_minor, started_at, finished_at) - fetched_pages(id, run_id, url, fetched_at, http_status, content_sha256, text) · stored separately from anything the model is told to follow - claims(id, draft_id, text, page_id, span_start, span_end, verified) · a claim with no span is an unsupported claim - drafts(id, run_id, revision, subject, body, created_at, approved_at, approved_by, sent_at, message_id) - suppression(email_or_domain, reason, added_at) · checked before a run starts and again before a send ### Phase 1 · A run is a ledger, not a loop Build: Define the run as an ordered list of steps persisted in SQLite. Write the step row with its input before execution, complete it with output, cost and timing afterwards. A resume command finds runs left in running, replays from the first incomplete step, and never re-executes a completed one. Trap: Holding the run in memory and writing only the final result means a crash on step five throws away four paid model calls and a browser session, and because the retry looks identical you pay for them again. Persist before you execute, and make resume the normal way a run finishes rather than an error path you test once. Done when: Killing the process during step three and running resume completes the run without re-executing steps one and two, tested. Every step row has a started_at before any side effect and a finished_at or an error afterwards, asserted in the suite. A run can be replayed from its ledger to show exactly what it did and what each step cost. Two resume commands running at once do not both claim the same run, tested with parallel invocations. Do not build yet: No fetching, no model calls, no drafting, no sending. ### Phase 2 · Fetching is quarantine Build: Fetch the target's home page and one about or pricing page with a headless browser. Extract readable text, hash it, store it in fetched_pages. Respect the site's crawl directives and identify yourself in the user agent. Never follow links off the target's own domain. Pass page text to the model only inside a delimited block labelled as untrusted content that the model is told to summarise, not obey. Trap: Concatenating fetched text straight into the prompt means any page can rewrite your agent's instructions, and the injected line does not have to be visible to a human reader to be read by the model. Keep the fetched text in its own field, wrap it in delimiters when you send it, state in the instruction that its contents are data, and show the operator the exact text the model saw. Done when: The instruction and the fetched text are separate arguments in the code and are never string-concatenated, verified by review and by a test on the prompt builder. A fixture page containing an instruction to email a different address produces a draft addressed to the original target, tested. Fetching is limited to the target's own domain, tested with a fixture that links elsewhere. The stored text hash matches what the operator is shown next to the draft. Do not build yet: No drafting, no claims, no cost ceilings, no sending. ### Phase 3 · A draft that can point at its evidence Build: Ask the model for a short message plus a list of claims, each with the exact quoted substring it came from. Verify each quote appears in the stored page text at the offsets given. A claim that fails verification is stripped and the draft is marked unsupported. Show the operator each claim next to its highlighted source. Trap: Asking the model to cite its sources and trusting the citation is the same mistake as trusting the summary, because a fabricated quote is as easy to generate as a fabricated fact. Verify every quoted span against the stored text by string search, and treat a quote that is not found as a failure of the draft rather than a formatting problem to repair. Done when: A claim whose quote does not appear in the stored page text is removed and the draft is flagged, tested with a fabricated citation. Claim verification is a string search against stored text and never a second model call, verified by review. The review screen shows each surviving claim with its source text highlighted. A draft with no verified claims is refused rather than sent for approval. Do not build yet: No ceilings, no approval gate, no sending, no suppression. ### Phase 4 · Ceilings, backoff and giving up loudly Build: Per-run and per-day spend ceilings from the environment, checked before every model and fetch step. Exponential backoff with a small maximum attempt count, then mark the step failed and leave the run resumable. A run that exceeds its ceiling stops in failed with the reason recorded, and the daily ceiling blocks new runs rather than truncating one in progress. Trap: Retrying a model call that returned a malformed response, with no attempt cap, produces a loop that spends money at whatever rate the provider allows and leaves nothing to show for it. Cap attempts, check the ceiling before the call rather than after, and record a failed step rather than retrying past the cap. Done when: A run that would exceed its ceiling stops before making the call that would cross it, tested at the boundary with a stubbed price. Attempts are capped and the cap is asserted in the suite with a fake that always fails. The daily ceiling blocks new runs while allowing an in-progress run to finish, tested. Every failure records a readable reason on the run and appears on the dashboard rather than only in a log. Do not build yet: No approval gate, no sending, no suppression list. ### Phase 5 · The approval gate and the send Build: Dry run is the default and sending requires both a configured credential and an explicit flag. The approve action is a button on the review screen that records who approved and when. Check the suppression list before a run starts and again immediately before the send. Store the message id and keep the full sent body. Provide an unsubscribe route that writes to suppression. Trap: An approve-all control on a list view is the feature that turns a careful design into a mass mailer, because approving twenty drafts by their subject lines is not reading them. Approve one draft at a time from the screen that shows its evidence, and if you want volume, raise the daily run ceiling rather than removing the reading. Done when: No code path sends without a per-draft approval recorded with an actor and a timestamp, asserted in the suite. Sending is impossible without the live flag and a configured credential, tested with both missing. A suppressed address is refused both at run creation and immediately before the send, tested at each point. Every sent message stores its final body, its claims and its message id, and an unsubscribe request adds a suppression row that a later run honours. Do not build yet: Nothing after this. ### Out of scope (and why) - Automating any site that requires a login, which means handling somebody's credentials in a browser the model influences and is the fastest way to turn a drafting tool into an incident. - CRM and mailbox integrations over OAuth, which is a maintenance commitment to somebody else's release schedule rather than a weekend of work. - Unattended sending on a schedule, since the approval gate is the entire safety argument and removing it removes the reason this build is defensible. - Deliverability and domain warm-up, which is a specialty in its own right and cannot be faked by sending more slowly. ### README must contain - That fetched web text is treated as untrusted data and never as instruction, with the mitigation list it follows - That the model holds no credentials, chooses no recipients and triggers no sends - That every claim in a draft is verified against stored page text by string search, and what an unsupported draft looks like - Where the per-run and per-day spend ceilings are set and what happens when they are reached - That the operator is responsible for deciding who may be contacted, and how the suppression list is maintained ===== AGENTS.md ===== # Agent instructions · Sumora indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript on Node with a small local HTTP server, bound to localhost, SQLite, with every step written before and after it runs, A headless browser for the target's own public pages only, One rented model, given only delimited untrusted text and no credentials, A mail credential held by the application, behind an explicit human approval. 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 - Holding the run in memory and writing only the final result means a crash on step five throws away four paid model calls and a browser session, and because the retry looks identical you pay for them again. Persist before you execute, and make resume the normal way a run finishes rather than an error path you test once. - Concatenating fetched text straight into the prompt means any page can rewrite your agent's instructions, and the injected line does not have to be visible to a human reader to be read by the model. Keep the fetched text in its own field, wrap it in delimiters when you send it, state in the instruction that its contents are data, and show the operator the exact text the model saw. - Asking the model to cite its sources and trusting the citation is the same mistake as trusting the summary, because a fabricated quote is as easy to generate as a fabricated fact. Verify every quoted span against the stored text by string search, and treat a quote that is not found as a failure of the draft rather than a formatting problem to repair. - Retrying a model call that returned a malformed response, with no attempt cap, produces a loop that spends money at whatever rate the provider allows and leaves nothing to show for it. Cap attempts, check the ceiling before the call rather than after, and record a failed step rather than retrying past the cap. ===== BUILD_PLAN.md ===== # Build plan · Sumora A single-operator outreach agent whose hard problems are treating fetched web text as hostile data rather than instruction, making every claim in a draft traceable to a span of that text, and keeping a multi-step run resumable and bounded in cost when a step fails halfway. 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 run is a ledger, not a loop Every step is written before it runs and completed after, so a crash resumes instead of restarting. ### Steps 1. Write the step before you execute it The row is the only proof a paid call happened. Files: `src/run/ledger.ts`, `tests/resume.test.ts` 2. Make resume replay from the first incomplete step Restarting from the top is how you pay twice. Files: `src/run/resume.ts` 3. Claim a run before resuming it Two resumes on one run is two outreach emails. Files: `src/run/claim.ts` ### Done when - [ ] A killed run resumes without repeating steps - [ ] Every step has start and end or an error - [ ] A run can be replayed from its ledger - [ ] Concurrent resumes claim distinct runs ### Watch out - Holding the run in memory and writing only the final result means a crash on step five throws away four paid model calls and a browser session, and because the retry looks identical you pay for them again. Persist before you execute, and make resume the normal way a run finishes rather than an error path you test once. ## Phase 2 · Fetching is quarantine Page text is captured, hashed and stored where nothing can mistake it for an instruction. ### Steps 1. Keep fetched text out of the instruction position Concatenation is the vulnerability, not the model. Files: `src/model/prompt.ts`, `tests/injection-fixture.test.ts` 2. Stay on the target's own domain and say who you are The evidence should be pages you could have opened yourself. Files: `src/fetch/browser.ts` 3. Hash and store exactly what the model was given So the operator reviews the same text the model read. Files: `src/fetch/store.ts` ### Done when - [ ] Instruction and page text are never concatenated - [ ] An injected instruction does not change the recipient - [ ] Fetching stays on the target's domain - [ ] Stored hash matches what is displayed ### Watch out - Concatenating fetched text straight into the prompt means any page can rewrite your agent's instructions, and the injected line does not have to be visible to a human reader to be read by the model. Keep the fetched text in its own field, wrap it in delimiters when you send it, state in the instruction that its contents are data, and show the operator the exact text the model saw. ## Phase 3 · A draft that can point at its evidence Every factual claim in the message maps to a span of fetched text, and one that does not blocks the draft. ### Steps 1. Ask for claims with exact quoted spans A quote is checkable; a summary is not. Files: `src/model/draft.ts` 2. Verify quotes by search, never by asking again A second model call verifies nothing. Files: `src/claims/verify.ts`, `tests/verify.test.ts` 3. Highlight the source beside each claim Reviewing is only fast when the evidence is adjacent. Files: `src/web/review.ts` ### Done when - [ ] A fabricated quote is stripped and flagged - [ ] Verification is a string search, not a model call - [ ] Claims display with highlighted sources - [ ] A draft with no verified claims is refused ### Watch out - Asking the model to cite its sources and trusting the citation is the same mistake as trusting the summary, because a fabricated quote is as easy to generate as a fabricated fact. Verify every quoted span against the stored text by string search, and treat a quote that is not found as a failure of the draft rather than a formatting problem to repair. ## Phase 4 · Ceilings, backoff and giving up loudly A confused run costs a known maximum and stops in a state you can see. ### Steps 1. Check the ceiling before the call, not after Afterwards is one call too late, every time. Files: `src/cost/ceiling.ts`, `tests/ceiling.test.ts` 2. Cap attempts and record the failure An uncapped retry is an uncapped bill. Files: `src/run/retry.ts` 3. Put failure reasons on the dashboard A log file is where failures go to be ignored. Files: `src/web/dashboard.ts` ### Done when - [ ] A run stops before crossing its ceiling - [ ] Attempts are capped and tested - [ ] The daily ceiling blocks new runs only - [ ] Failure reasons are visible in the interface ### Watch out - Retrying a model call that returned a malformed response, with no attempt cap, produces a loop that spends money at whatever rate the provider allows and leaves nothing to show for it. Cap attempts, check the ceiling before the call rather than after, and record a failed step rather than retrying past the cap. ## Not in this build - Automation of any logged-in site. - CRM and mailbox integrations. - Unattended scheduled sending. - Deliverability and domain warm-up. ## After v1, if you want it - A second reviewer mode where approval needs two distinct actors, which is what a small team would actually want - A per-target research cache so a repeat run reads nothing it already holds - An export of every sent message with its claims and sources, which is the file you want the day somebody asks ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The drafting model. Give it a provider-side spend limit as well. MODEL_API_KEY=sk-...redacted # Required. Runs, steps, fetched pages, claims and drafts. DATABASE_PATH=./data/agent.db # Required. A single run stops before crossing this figure. RUN_COST_CEILING=0.50 # Required. New runs are blocked at this figure; a run in progress finishes. DAILY_COST_CEILING=5.00 # Required. Per step. Beyond this the step fails and the run stays resumable. MAX_ATTEMPTS=3 # Optional · secret. Absent by default. Sending also needs the live flag. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Required. Dry run is the default and this is the second of the two locks on sending. LIVE_SEND=false
You are building a production product version of Sumora. 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 ===== # Sumora · product brief ## Problem The visible surface, a prompt box that produces a researched, personalized outreach message, is a one-sitting build. What takes real work is everything that keeps an agent from quietly going insane on step seven: persistent memory about your business and each contact, retries, browser sessions that survive logins and captchas, cost ceilings, and a human approval gate before anything is actually sent. For one person automating their own pipeline, you can get to genuinely useful in a weekend because you are allowed to be the approval step and the error handler. The gap widens fast the moment you want it unattended, multi-user, or connected to a real CRM and mailbox over OAuth. The failure mode also matters: a broken personal script wastes your evening, a broken agent emails the wrong prospect from your domain. ## Product outcome An outreach agent where a hostile web page can at worst produce a strange draft, every claim is checkable against stored text, a confused run costs a known maximum, and nothing reaches a person without somebody reading it first. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - An LLM API key - Playwright and a machine willing to run a real browser - A search API or scraping tolerance - SMTP credentials or a mail API if you want it to actually send - Patience for step-level debugging ## Explicit non-goals for v1 - Automation of any logged-in site. - CRM and mailbox integrations. - Unattended scheduled sending. - Deliverability and domain warm-up. - Unattended reliability: retries, resumable runs, and recovery when a step fails halfway through - Maintained integrations and OAuth to CRMs, mailboxes, and calendars, plus keeping them working when those APIs shift - Sandboxed execution and guardrails so a confused agent cannot take a real action against a real person - Cost control and model routing, so one runaway loop does not become a surprise bill - Deliverability and sending reputation work, which is its own unglamorous specialty ## Success criteria - An injected instruction in a fixture page is confirmed not to change the recipient - A fabricated quote is confirmed stripped and the draft flagged - A run is confirmed to stop before crossing its cost ceiling - Sending is confirmed impossible without both the flag and the credential - No bulk approval path is confirmed to exist ===== BRIEF.md ===== # Build brief · Sumora 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 local research agent that reads a company's own pages, drafts one message with its evidence attached, and cannot send anything you have not read to replace Sumora. 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 Text the agent fetched from the open web is data. It is never instruction. The OWASP Top 10 for large language model applications lists prompt injection as its first entry, and describes indirect prompt injection as the case where content from a website or a file, once interpreted by the model, changes the model's behaviour in ways nobody intended. Its mitigations are the design of this build: privilege control, so the application holds the credentials and the model does not; a human in the loop for privileged operations; and clear separation of untrusted content from the instruction. Concretely, that means the model never holds the mail credential, never chooses a recipient address, and never triggers a send. Fetched page text is stored in its own field, passed in a delimited block labelled as untrusted, and shown to you beside the draft. If a fetched page contains a line telling the agent to email someone else, the worst that can happen is a strange draft you delete. Second, and it is not a technical question: cold outreach to people who did not ask for it is regulated differently in different places, and the rules that apply depend on where the recipient is, not where you are. Decide who you are allowed to contact before you build the sender, record for each address why it was contacted and where it came from, and build the suppression list in Phase 5 before the first live send rather than after the first complaint. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node with a small local HTTP server, bound to localhost · one operator reviewing drafts, no accounts and nothing exposed - Storage: SQLite, with every step written before and after it runs · a crashed run must be resumable, and that means the ledger is the state - Fetching: A headless browser for the target's own public pages only · the evidence has to come from somewhere you could have read yourself - Model: One rented model, given only delimited untrusted text and no credentials · the model drafts, it does not act, and that split is the whole safety story - Sending: A mail credential held by the application, behind an explicit human approval · nothing leaves without somebody reading it, and that somebody is not the model ### Data model (create this before Phase 1) - business_context(id, body, updated_at) · your offer, tone and hard rules, edited by you and never by the model - targets(id, company, domain, contact_name, contact_email, source, source_note, added_at, suppressed_at) - runs(id, target_id, state, created_at, finished_at, cost_minor, failure_reason) · states are queued, running, needs_review, approved, sent, failed - steps(id, run_id, ordinal, tool, input_json, output_json, error, tokens_in, tokens_out, cost_minor, started_at, finished_at) - fetched_pages(id, run_id, url, fetched_at, http_status, content_sha256, text) · stored separately from anything the model is told to follow - claims(id, draft_id, text, page_id, span_start, span_end, verified) · a claim with no span is an unsupported claim - drafts(id, run_id, revision, subject, body, created_at, approved_at, approved_by, sent_at, message_id) - suppression(email_or_domain, reason, added_at) · checked before a run starts and again before a send ### Phase 1 · A run is a ledger, not a loop Build: Define the run as an ordered list of steps persisted in SQLite. Write the step row with its input before execution, complete it with output, cost and timing afterwards. A resume command finds runs left in running, replays from the first incomplete step, and never re-executes a completed one. Trap: Holding the run in memory and writing only the final result means a crash on step five throws away four paid model calls and a browser session, and because the retry looks identical you pay for them again. Persist before you execute, and make resume the normal way a run finishes rather than an error path you test once. Done when: Killing the process during step three and running resume completes the run without re-executing steps one and two, tested. Every step row has a started_at before any side effect and a finished_at or an error afterwards, asserted in the suite. A run can be replayed from its ledger to show exactly what it did and what each step cost. Two resume commands running at once do not both claim the same run, tested with parallel invocations. Do not build yet: No fetching, no model calls, no drafting, no sending. ### Phase 2 · Fetching is quarantine Build: Fetch the target's home page and one about or pricing page with a headless browser. Extract readable text, hash it, store it in fetched_pages. Respect the site's crawl directives and identify yourself in the user agent. Never follow links off the target's own domain. Pass page text to the model only inside a delimited block labelled as untrusted content that the model is told to summarise, not obey. Trap: Concatenating fetched text straight into the prompt means any page can rewrite your agent's instructions, and the injected line does not have to be visible to a human reader to be read by the model. Keep the fetched text in its own field, wrap it in delimiters when you send it, state in the instruction that its contents are data, and show the operator the exact text the model saw. Done when: The instruction and the fetched text are separate arguments in the code and are never string-concatenated, verified by review and by a test on the prompt builder. A fixture page containing an instruction to email a different address produces a draft addressed to the original target, tested. Fetching is limited to the target's own domain, tested with a fixture that links elsewhere. The stored text hash matches what the operator is shown next to the draft. Do not build yet: No drafting, no claims, no cost ceilings, no sending. ### Phase 3 · A draft that can point at its evidence Build: Ask the model for a short message plus a list of claims, each with the exact quoted substring it came from. Verify each quote appears in the stored page text at the offsets given. A claim that fails verification is stripped and the draft is marked unsupported. Show the operator each claim next to its highlighted source. Trap: Asking the model to cite its sources and trusting the citation is the same mistake as trusting the summary, because a fabricated quote is as easy to generate as a fabricated fact. Verify every quoted span against the stored text by string search, and treat a quote that is not found as a failure of the draft rather than a formatting problem to repair. Done when: A claim whose quote does not appear in the stored page text is removed and the draft is flagged, tested with a fabricated citation. Claim verification is a string search against stored text and never a second model call, verified by review. The review screen shows each surviving claim with its source text highlighted. A draft with no verified claims is refused rather than sent for approval. Do not build yet: No ceilings, no approval gate, no sending, no suppression. ### Phase 4 · Ceilings, backoff and giving up loudly Build: Per-run and per-day spend ceilings from the environment, checked before every model and fetch step. Exponential backoff with a small maximum attempt count, then mark the step failed and leave the run resumable. A run that exceeds its ceiling stops in failed with the reason recorded, and the daily ceiling blocks new runs rather than truncating one in progress. Trap: Retrying a model call that returned a malformed response, with no attempt cap, produces a loop that spends money at whatever rate the provider allows and leaves nothing to show for it. Cap attempts, check the ceiling before the call rather than after, and record a failed step rather than retrying past the cap. Done when: A run that would exceed its ceiling stops before making the call that would cross it, tested at the boundary with a stubbed price. Attempts are capped and the cap is asserted in the suite with a fake that always fails. The daily ceiling blocks new runs while allowing an in-progress run to finish, tested. Every failure records a readable reason on the run and appears on the dashboard rather than only in a log. Do not build yet: No approval gate, no sending, no suppression list. ### Phase 5 · The approval gate and the send Build: Dry run is the default and sending requires both a configured credential and an explicit flag. The approve action is a button on the review screen that records who approved and when. Check the suppression list before a run starts and again immediately before the send. Store the message id and keep the full sent body. Provide an unsubscribe route that writes to suppression. Trap: An approve-all control on a list view is the feature that turns a careful design into a mass mailer, because approving twenty drafts by their subject lines is not reading them. Approve one draft at a time from the screen that shows its evidence, and if you want volume, raise the daily run ceiling rather than removing the reading. Done when: No code path sends without a per-draft approval recorded with an actor and a timestamp, asserted in the suite. Sending is impossible without the live flag and a configured credential, tested with both missing. A suppressed address is refused both at run creation and immediately before the send, tested at each point. Every sent message stores its final body, its claims and its message id, and an unsubscribe request adds a suppression row that a later run honours. Do not build yet: Nothing after this. ### Out of scope (and why) - Automating any site that requires a login, which means handling somebody's credentials in a browser the model influences and is the fastest way to turn a drafting tool into an incident. - CRM and mailbox integrations over OAuth, which is a maintenance commitment to somebody else's release schedule rather than a weekend of work. - Unattended sending on a schedule, since the approval gate is the entire safety argument and removing it removes the reason this build is defensible. - Deliverability and domain warm-up, which is a specialty in its own right and cannot be faked by sending more slowly. ### README must contain - That fetched web text is treated as untrusted data and never as instruction, with the mitigation list it follows - That the model holds no credentials, chooses no recipients and triggers no sends - That every claim in a draft is verified against stored page text by string search, and what an unsupported draft looks like - Where the per-run and per-day spend ceilings are set and what happens when they are reached - That the operator is responsible for deciding who may be contacted, and how the suppression list is maintained ===== ARCHITECTURE.md ===== # Architecture · Sumora ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript on Node with a small local HTTP server, bound to localhost | one operator reviewing drafts, no accounts and nothing exposed | | Storage | SQLite, with every step written before and after it runs | a crashed run must be resumable, and that means the ledger is the state | | Fetching | A headless browser for the target's own public pages only | the evidence has to come from somewhere you could have read yourself | | Model | One rented model, given only delimited untrusted text and no credentials | the model drafts, it does not act, and that split is the whole safety story | | Sending | A mail credential held by the application, behind an explicit human approval | nothing leaves without somebody reading it, and that somebody is not the model | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ledger | Run and step persistence, resume and claiming. | Any store, provided the row is written before the side effect. | | Fetch | Browser capture, domain limits and text hashing. | Any fetcher, provided the text never enters the instruction position. | | Draft | Model calls, claims and span verification. | Any model, provided quotes are verified by search against stored text. | | Cost | Per-run and per-day ceilings and attempt caps. | Any accounting, provided the check happens before the call. | | Send | Approval records, suppression and message retention. | Not swappable for anything that can send without a per-draft approval. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `MODEL_API_KEY` · required, secret · The drafting model. Give it a provider-side spend limit as well. - `DATABASE_PATH` · required · Runs, steps, fetched pages, claims and drafts. - `RUN_COST_CEILING` · required · A single run stops before crossing this figure. - `DAILY_COST_CEILING` · required · New runs are blocked at this figure; a run in progress finishes. - `MAX_ATTEMPTS` · required · Per step. Beyond this the step fails and the run stays resumable. - `SMTP_URL` · optional, secret · Absent by default. Sending also needs the live flag. - `LIVE_SEND` · required · Dry run is the default and this is the second of the two locks on sending. ## 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 · Sumora product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript on Node with a small local HTTP server, bound to localhost, SQLite, with every step written before and after it runs, A headless browser for the target's own public pages only, One rented model, given only delimited untrusted text and no credentials, A mail credential held by the application, behind an explicit human approval. - 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 - Holding the run in memory and writing only the final result means a crash on step five throws away four paid model calls and a browser session, and because the retry looks identical you pay for them again. Persist before you execute, and make resume the normal way a run finishes rather than an error path you test once. - Concatenating fetched text straight into the prompt means any page can rewrite your agent's instructions, and the injected line does not have to be visible to a human reader to be read by the model. Keep the fetched text in its own field, wrap it in delimiters when you send it, state in the instruction that its contents are data, and show the operator the exact text the model saw. - Asking the model to cite its sources and trusting the citation is the same mistake as trusting the summary, because a fabricated quote is as easy to generate as a fabricated fact. Verify every quoted span against the stored text by string search, and treat a quote that is not found as a failure of the draft rather than a formatting problem to repair. - Retrying a model call that returned a malformed response, with no attempt cap, produces a loop that spends money at whatever rate the provider allows and leaves nothing to show for it. Cap attempts, check the ceiling before the call rather than after, and record a failed step rather than retrying past the cap. - An approve-all control on a list view is the feature that turns a careful design into a mass mailer, because approving twenty drafts by their subject lines is not reading them. Approve one draft at a time from the screen that shows its evidence, and if you want volume, raise the daily run ceiling rather than removing the reading. ===== MILESTONES.md ===== # Delivery milestones · Sumora Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A run is a ledger, not a loop Every step is written before it runs and completed after, so a crash resumes instead of restarting. ### Steps 1. Write the step before you execute it The row is the only proof a paid call happened. Files: `src/run/ledger.ts`, `tests/resume.test.ts` 2. Make resume replay from the first incomplete step Restarting from the top is how you pay twice. Files: `src/run/resume.ts` 3. Claim a run before resuming it Two resumes on one run is two outreach emails. Files: `src/run/claim.ts` ### Done when - [ ] A killed run resumes without repeating steps - [ ] Every step has start and end or an error - [ ] A run can be replayed from its ledger - [ ] Concurrent resumes claim distinct runs ### Watch out - Holding the run in memory and writing only the final result means a crash on step five throws away four paid model calls and a browser session, and because the retry looks identical you pay for them again. Persist before you execute, and make resume the normal way a run finishes rather than an error path you test once. ## M2 · Fetching is quarantine Page text is captured, hashed and stored where nothing can mistake it for an instruction. ### Steps 1. Keep fetched text out of the instruction position Concatenation is the vulnerability, not the model. Files: `src/model/prompt.ts`, `tests/injection-fixture.test.ts` 2. Stay on the target's own domain and say who you are The evidence should be pages you could have opened yourself. Files: `src/fetch/browser.ts` 3. Hash and store exactly what the model was given So the operator reviews the same text the model read. Files: `src/fetch/store.ts` ### Done when - [ ] Instruction and page text are never concatenated - [ ] An injected instruction does not change the recipient - [ ] Fetching stays on the target's domain - [ ] Stored hash matches what is displayed ### Watch out - Concatenating fetched text straight into the prompt means any page can rewrite your agent's instructions, and the injected line does not have to be visible to a human reader to be read by the model. Keep the fetched text in its own field, wrap it in delimiters when you send it, state in the instruction that its contents are data, and show the operator the exact text the model saw. ## M3 · A draft that can point at its evidence Every factual claim in the message maps to a span of fetched text, and one that does not blocks the draft. ### Steps 1. Ask for claims with exact quoted spans A quote is checkable; a summary is not. Files: `src/model/draft.ts` 2. Verify quotes by search, never by asking again A second model call verifies nothing. Files: `src/claims/verify.ts`, `tests/verify.test.ts` 3. Highlight the source beside each claim Reviewing is only fast when the evidence is adjacent. Files: `src/web/review.ts` ### Done when - [ ] A fabricated quote is stripped and flagged - [ ] Verification is a string search, not a model call - [ ] Claims display with highlighted sources - [ ] A draft with no verified claims is refused ### Watch out - Asking the model to cite its sources and trusting the citation is the same mistake as trusting the summary, because a fabricated quote is as easy to generate as a fabricated fact. Verify every quoted span against the stored text by string search, and treat a quote that is not found as a failure of the draft rather than a formatting problem to repair. ## M4 · Ceilings, backoff and giving up loudly A confused run costs a known maximum and stops in a state you can see. ### Steps 1. Check the ceiling before the call, not after Afterwards is one call too late, every time. Files: `src/cost/ceiling.ts`, `tests/ceiling.test.ts` 2. Cap attempts and record the failure An uncapped retry is an uncapped bill. Files: `src/run/retry.ts` 3. Put failure reasons on the dashboard A log file is where failures go to be ignored. Files: `src/web/dashboard.ts` ### Done when - [ ] A run stops before crossing its ceiling - [ ] Attempts are capped and tested - [ ] The daily ceiling blocks new runs only - [ ] Failure reasons are visible in the interface ### Watch out - Retrying a model call that returned a malformed response, with no attempt cap, produces a loop that spends money at whatever rate the provider allows and leaves nothing to show for it. Cap attempts, check the ceiling before the call rather than after, and record a failed step rather than retrying past the cap. ## M5 · The approval gate and the send (production only) Nothing reaches a real person without a human reading it, and the record of why survives. ### Steps 1. Approve one draft at a time, from its evidence Batch approval is not review, whatever the button says. Files: `src/web/approve.ts`, `tests/no-bulk-approve.test.ts` 2. Check suppression twice, at both ends of the run The list can change while a run is in flight. Files: `src/suppression/check.ts` 3. Store the sent body, not a template reference You will need to know exactly what they received. Files: `src/send/record.ts` ### Done when - [ ] Every send has a recorded per-draft approval - [ ] Sending needs both the flag and the credential - [ ] Suppression is checked at start and at send - [ ] Sent bodies and message ids are retained ### Watch out - An approve-all control on a list view is the feature that turns a careful design into a mass mailer, because approving twenty drafts by their subject lines is not reading them. Approve one draft at a time from the screen that shows its evidence, and if you want volume, raise the daily run ceiling rather than removing the reading. ===== OPERATIONS.md ===== # Operations · Sumora ## Backup The SQLite file, which holds the runs, the fetched evidence and every sent body. The sent bodies are the part you cannot reconstruct and the part you will be asked about. ## Restore Restore, run resume, and confirm that no run in the restored database is in approved but unsent. That state after a restore is the one that can send something twice. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Daily spend against the ceiling, runs sitting in needs_review, the rate of drafts refused for unsupported claims, and any run that resumed more than once. A rising refusal rate usually means the fetch step is returning navigation text rather than content. ## Incident checklist The incident is a message that should not have gone out. The ledger, the stored page text, the verified claims and the approval record together reconstruct what the operator saw at the moment they approved, which is the only account worth having. 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 - [ ] An injected instruction in a fixture page is confirmed not to change the recipient - [ ] A fabricated quote is confirmed stripped and the draft flagged - [ ] A run is confirmed to stop before crossing its cost ceiling - [ ] Sending is confirmed impossible without both the flag and the credential - [ ] No bulk approval path is confirmed to exist ## Launch constraint Do not market omitted Sumora 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 · secret. The drafting model. Give it a provider-side spend limit as well. MODEL_API_KEY=sk-...redacted # Required. Runs, steps, fetched pages, claims and drafts. DATABASE_PATH=./data/agent.db # Required. A single run stops before crossing this figure. RUN_COST_CEILING=0.50 # Required. New runs are blocked at this figure; a run in progress finishes. DAILY_COST_CEILING=5.00 # Required. Per step. Beyond this the step fails and the run stays resumable. MAX_ATTEMPTS=3 # Optional · secret. Absent by default. Sending also needs the live flag. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Required. Dry run is the default and this is the second of the two locks on sending. LIVE_SEND=false
# Sumora · indie build A single-operator outreach agent whose hard problems are treating fetched web text as hostile data rather than instruction, making every claim in a draft traceable to a span of that text, and keeping a multi-step run resumable and bounded in cost when a step fails halfway. Estimated effort: **weekend**. 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 on Node with a small local HTTP server, bound to localhost | one operator reviewing drafts, no accounts and nothing exposed | | Storage | SQLite, with every step written before and after it runs | a crashed run must be resumable, and that means the ledger is the state | | Fetching | A headless browser for the target's own public pages only | the evidence has to come from somewhere you could have read yourself | | Model | One rented model, given only delimited untrusted text and no credentials | the model drafts, it does not act, and that split is the whole safety story | | Sending | A mail credential held by the application, behind an explicit human approval | nothing leaves without somebody reading it, and that somebody is not the model | ## 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 agent, the local review interface and the worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A headless browser automation library with its browser downloaded** · free - Why: Reading a target's own pages needs a real renderer, and this is the only heavy dependency in the build. - Get it: Install the library and run its browser download step. - Verify: A fixture page loads and readable text comes back - [ ] **An API key for a text model** · pay per token; set a provider-side limit too - Why: The drafting step is the only rented part, and the ceilings in this build are what keep it from being the expensive one. - Get it: Create a key with your provider and set a spend limit in their console as well as in this application. - Verify: One run against a local fixture produces a draft with claims - [ ] **Mail sending credentials** (optional) · free tiers are ample at this volume - Why: Required only for live sending, and deliberately absent by default so that dry run is the state you cannot accidentally leave. - Get it: Any transactional sender with a verified domain you control. - Verify: A test message reaches your own inbox with the right sender - [ ] **Who you are allowed to contact** · free - Why: The rules depend on where the recipient is, and retrofitting a contact policy after a campaign is not a thing you can do. - Get it: Write down which jurisdictions you will contact, what basis you rely on, and what source note every target must carry. - Verify: The policy is in the README and every target row has a source and a note ## 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: - Automation of any logged-in site. - CRM and mailbox integrations. - Unattended scheduled sending. - Deliverability and domain warm-up. - Unattended reliability: retries, resumable runs, and recovery when a step fails halfway through - Maintained integrations and OAuth to CRMs, mailboxes, and calendars, plus keeping them working when those APIs shift - Sandboxed execution and guardrails so a confused agent cannot take a real action against a real person - Cost control and model routing, so one runaway loop does not become a surprise bill - Deliverability and sending reputation work, which is its own unglamorous specialty If one of those is essential to you, that is the reason to keep paying for Sumora, and the README should say so rather than pretend.
# Build brief · Sumora 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 local research agent that reads a company's own pages, drafts one message with its evidence attached, and cannot send anything you have not read to replace Sumora. 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 Text the agent fetched from the open web is data. It is never instruction. The OWASP Top 10 for large language model applications lists prompt injection as its first entry, and describes indirect prompt injection as the case where content from a website or a file, once interpreted by the model, changes the model's behaviour in ways nobody intended. Its mitigations are the design of this build: privilege control, so the application holds the credentials and the model does not; a human in the loop for privileged operations; and clear separation of untrusted content from the instruction. Concretely, that means the model never holds the mail credential, never chooses a recipient address, and never triggers a send. Fetched page text is stored in its own field, passed in a delimited block labelled as untrusted, and shown to you beside the draft. If a fetched page contains a line telling the agent to email someone else, the worst that can happen is a strange draft you delete. Second, and it is not a technical question: cold outreach to people who did not ask for it is regulated differently in different places, and the rules that apply depend on where the recipient is, not where you are. Decide who you are allowed to contact before you build the sender, record for each address why it was contacted and where it came from, and build the suppression list in Phase 5 before the first live send rather than after the first complaint. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node with a small local HTTP server, bound to localhost · one operator reviewing drafts, no accounts and nothing exposed - Storage: SQLite, with every step written before and after it runs · a crashed run must be resumable, and that means the ledger is the state - Fetching: A headless browser for the target's own public pages only · the evidence has to come from somewhere you could have read yourself - Model: One rented model, given only delimited untrusted text and no credentials · the model drafts, it does not act, and that split is the whole safety story - Sending: A mail credential held by the application, behind an explicit human approval · nothing leaves without somebody reading it, and that somebody is not the model ### Data model (create this before Phase 1) - business_context(id, body, updated_at) · your offer, tone and hard rules, edited by you and never by the model - targets(id, company, domain, contact_name, contact_email, source, source_note, added_at, suppressed_at) - runs(id, target_id, state, created_at, finished_at, cost_minor, failure_reason) · states are queued, running, needs_review, approved, sent, failed - steps(id, run_id, ordinal, tool, input_json, output_json, error, tokens_in, tokens_out, cost_minor, started_at, finished_at) - fetched_pages(id, run_id, url, fetched_at, http_status, content_sha256, text) · stored separately from anything the model is told to follow - claims(id, draft_id, text, page_id, span_start, span_end, verified) · a claim with no span is an unsupported claim - drafts(id, run_id, revision, subject, body, created_at, approved_at, approved_by, sent_at, message_id) - suppression(email_or_domain, reason, added_at) · checked before a run starts and again before a send ### Phase 1 · A run is a ledger, not a loop Build: Define the run as an ordered list of steps persisted in SQLite. Write the step row with its input before execution, complete it with output, cost and timing afterwards. A resume command finds runs left in running, replays from the first incomplete step, and never re-executes a completed one. Trap: Holding the run in memory and writing only the final result means a crash on step five throws away four paid model calls and a browser session, and because the retry looks identical you pay for them again. Persist before you execute, and make resume the normal way a run finishes rather than an error path you test once. Done when: Killing the process during step three and running resume completes the run without re-executing steps one and two, tested. Every step row has a started_at before any side effect and a finished_at or an error afterwards, asserted in the suite. A run can be replayed from its ledger to show exactly what it did and what each step cost. Two resume commands running at once do not both claim the same run, tested with parallel invocations. Do not build yet: No fetching, no model calls, no drafting, no sending. ### Phase 2 · Fetching is quarantine Build: Fetch the target's home page and one about or pricing page with a headless browser. Extract readable text, hash it, store it in fetched_pages. Respect the site's crawl directives and identify yourself in the user agent. Never follow links off the target's own domain. Pass page text to the model only inside a delimited block labelled as untrusted content that the model is told to summarise, not obey. Trap: Concatenating fetched text straight into the prompt means any page can rewrite your agent's instructions, and the injected line does not have to be visible to a human reader to be read by the model. Keep the fetched text in its own field, wrap it in delimiters when you send it, state in the instruction that its contents are data, and show the operator the exact text the model saw. Done when: The instruction and the fetched text are separate arguments in the code and are never string-concatenated, verified by review and by a test on the prompt builder. A fixture page containing an instruction to email a different address produces a draft addressed to the original target, tested. Fetching is limited to the target's own domain, tested with a fixture that links elsewhere. The stored text hash matches what the operator is shown next to the draft. Do not build yet: No drafting, no claims, no cost ceilings, no sending. ### Phase 3 · A draft that can point at its evidence Build: Ask the model for a short message plus a list of claims, each with the exact quoted substring it came from. Verify each quote appears in the stored page text at the offsets given. A claim that fails verification is stripped and the draft is marked unsupported. Show the operator each claim next to its highlighted source. Trap: Asking the model to cite its sources and trusting the citation is the same mistake as trusting the summary, because a fabricated quote is as easy to generate as a fabricated fact. Verify every quoted span against the stored text by string search, and treat a quote that is not found as a failure of the draft rather than a formatting problem to repair. Done when: A claim whose quote does not appear in the stored page text is removed and the draft is flagged, tested with a fabricated citation. Claim verification is a string search against stored text and never a second model call, verified by review. The review screen shows each surviving claim with its source text highlighted. A draft with no verified claims is refused rather than sent for approval. Do not build yet: No ceilings, no approval gate, no sending, no suppression. ### Phase 4 · Ceilings, backoff and giving up loudly Build: Per-run and per-day spend ceilings from the environment, checked before every model and fetch step. Exponential backoff with a small maximum attempt count, then mark the step failed and leave the run resumable. A run that exceeds its ceiling stops in failed with the reason recorded, and the daily ceiling blocks new runs rather than truncating one in progress. Trap: Retrying a model call that returned a malformed response, with no attempt cap, produces a loop that spends money at whatever rate the provider allows and leaves nothing to show for it. Cap attempts, check the ceiling before the call rather than after, and record a failed step rather than retrying past the cap. Done when: A run that would exceed its ceiling stops before making the call that would cross it, tested at the boundary with a stubbed price. Attempts are capped and the cap is asserted in the suite with a fake that always fails. The daily ceiling blocks new runs while allowing an in-progress run to finish, tested. Every failure records a readable reason on the run and appears on the dashboard rather than only in a log. Do not build yet: No approval gate, no sending, no suppression list. ### Phase 5 · The approval gate and the send Build: Dry run is the default and sending requires both a configured credential and an explicit flag. The approve action is a button on the review screen that records who approved and when. Check the suppression list before a run starts and again immediately before the send. Store the message id and keep the full sent body. Provide an unsubscribe route that writes to suppression. Trap: An approve-all control on a list view is the feature that turns a careful design into a mass mailer, because approving twenty drafts by their subject lines is not reading them. Approve one draft at a time from the screen that shows its evidence, and if you want volume, raise the daily run ceiling rather than removing the reading. Done when: No code path sends without a per-draft approval recorded with an actor and a timestamp, asserted in the suite. Sending is impossible without the live flag and a configured credential, tested with both missing. A suppressed address is refused both at run creation and immediately before the send, tested at each point. Every sent message stores its final body, its claims and its message id, and an unsubscribe request adds a suppression row that a later run honours. Do not build yet: Nothing after this. ### Out of scope (and why) - Automating any site that requires a login, which means handling somebody's credentials in a browser the model influences and is the fastest way to turn a drafting tool into an incident. - CRM and mailbox integrations over OAuth, which is a maintenance commitment to somebody else's release schedule rather than a weekend of work. - Unattended sending on a schedule, since the approval gate is the entire safety argument and removing it removes the reason this build is defensible. - Deliverability and domain warm-up, which is a specialty in its own right and cannot be faked by sending more slowly. ### README must contain - That fetched web text is treated as untrusted data and never as instruction, with the mitigation list it follows - That the model holds no credentials, chooses no recipients and triggers no sends - That every claim in a draft is verified against stored page text by string search, and what an unsupported draft looks like - Where the per-run and per-day spend ceilings are set and what happens when they are reached - That the operator is responsible for deciding who may be contacted, and how the suppression list is maintained
# Agent instructions · Sumora indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript on Node with a small local HTTP server, bound to localhost, SQLite, with every step written before and after it runs, A headless browser for the target's own public pages only, One rented model, given only delimited untrusted text and no credentials, A mail credential held by the application, behind an explicit human approval. 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 - Holding the run in memory and writing only the final result means a crash on step five throws away four paid model calls and a browser session, and because the retry looks identical you pay for them again. Persist before you execute, and make resume the normal way a run finishes rather than an error path you test once. - Concatenating fetched text straight into the prompt means any page can rewrite your agent's instructions, and the injected line does not have to be visible to a human reader to be read by the model. Keep the fetched text in its own field, wrap it in delimiters when you send it, state in the instruction that its contents are data, and show the operator the exact text the model saw. - Asking the model to cite its sources and trusting the citation is the same mistake as trusting the summary, because a fabricated quote is as easy to generate as a fabricated fact. Verify every quoted span against the stored text by string search, and treat a quote that is not found as a failure of the draft rather than a formatting problem to repair. - Retrying a model call that returned a malformed response, with no attempt cap, produces a loop that spends money at whatever rate the provider allows and leaves nothing to show for it. Cap attempts, check the ceiling before the call rather than after, and record a failed step rather than retrying past the cap.
# Build plan · Sumora A single-operator outreach agent whose hard problems are treating fetched web text as hostile data rather than instruction, making every claim in a draft traceable to a span of that text, and keeping a multi-step run resumable and bounded in cost when a step fails halfway. 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 run is a ledger, not a loop Every step is written before it runs and completed after, so a crash resumes instead of restarting. ### Steps 1. Write the step before you execute it The row is the only proof a paid call happened. Files: `src/run/ledger.ts`, `tests/resume.test.ts` 2. Make resume replay from the first incomplete step Restarting from the top is how you pay twice. Files: `src/run/resume.ts` 3. Claim a run before resuming it Two resumes on one run is two outreach emails. Files: `src/run/claim.ts` ### Done when - [ ] A killed run resumes without repeating steps - [ ] Every step has start and end or an error - [ ] A run can be replayed from its ledger - [ ] Concurrent resumes claim distinct runs ### Watch out - Holding the run in memory and writing only the final result means a crash on step five throws away four paid model calls and a browser session, and because the retry looks identical you pay for them again. Persist before you execute, and make resume the normal way a run finishes rather than an error path you test once. ## Phase 2 · Fetching is quarantine Page text is captured, hashed and stored where nothing can mistake it for an instruction. ### Steps 1. Keep fetched text out of the instruction position Concatenation is the vulnerability, not the model. Files: `src/model/prompt.ts`, `tests/injection-fixture.test.ts` 2. Stay on the target's own domain and say who you are The evidence should be pages you could have opened yourself. Files: `src/fetch/browser.ts` 3. Hash and store exactly what the model was given So the operator reviews the same text the model read. Files: `src/fetch/store.ts` ### Done when - [ ] Instruction and page text are never concatenated - [ ] An injected instruction does not change the recipient - [ ] Fetching stays on the target's domain - [ ] Stored hash matches what is displayed ### Watch out - Concatenating fetched text straight into the prompt means any page can rewrite your agent's instructions, and the injected line does not have to be visible to a human reader to be read by the model. Keep the fetched text in its own field, wrap it in delimiters when you send it, state in the instruction that its contents are data, and show the operator the exact text the model saw. ## Phase 3 · A draft that can point at its evidence Every factual claim in the message maps to a span of fetched text, and one that does not blocks the draft. ### Steps 1. Ask for claims with exact quoted spans A quote is checkable; a summary is not. Files: `src/model/draft.ts` 2. Verify quotes by search, never by asking again A second model call verifies nothing. Files: `src/claims/verify.ts`, `tests/verify.test.ts` 3. Highlight the source beside each claim Reviewing is only fast when the evidence is adjacent. Files: `src/web/review.ts` ### Done when - [ ] A fabricated quote is stripped and flagged - [ ] Verification is a string search, not a model call - [ ] Claims display with highlighted sources - [ ] A draft with no verified claims is refused ### Watch out - Asking the model to cite its sources and trusting the citation is the same mistake as trusting the summary, because a fabricated quote is as easy to generate as a fabricated fact. Verify every quoted span against the stored text by string search, and treat a quote that is not found as a failure of the draft rather than a formatting problem to repair. ## Phase 4 · Ceilings, backoff and giving up loudly A confused run costs a known maximum and stops in a state you can see. ### Steps 1. Check the ceiling before the call, not after Afterwards is one call too late, every time. Files: `src/cost/ceiling.ts`, `tests/ceiling.test.ts` 2. Cap attempts and record the failure An uncapped retry is an uncapped bill. Files: `src/run/retry.ts` 3. Put failure reasons on the dashboard A log file is where failures go to be ignored. Files: `src/web/dashboard.ts` ### Done when - [ ] A run stops before crossing its ceiling - [ ] Attempts are capped and tested - [ ] The daily ceiling blocks new runs only - [ ] Failure reasons are visible in the interface ### Watch out - Retrying a model call that returned a malformed response, with no attempt cap, produces a loop that spends money at whatever rate the provider allows and leaves nothing to show for it. Cap attempts, check the ceiling before the call rather than after, and record a failed step rather than retrying past the cap. ## Not in this build - Automation of any logged-in site. - CRM and mailbox integrations. - Unattended scheduled sending. - Deliverability and domain warm-up. ## After v1, if you want it - A second reviewer mode where approval needs two distinct actors, which is what a small team would actually want - A per-target research cache so a repeat run reads nothing it already holds - An export of every sent message with its claims and sources, which is the file you want the day somebody asks
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The drafting model. Give it a provider-side spend limit as well. MODEL_API_KEY=sk-...redacted # Required. Runs, steps, fetched pages, claims and drafts. DATABASE_PATH=./data/agent.db # Required. A single run stops before crossing this figure. RUN_COST_CEILING=0.50 # Required. New runs are blocked at this figure; a run in progress finishes. DAILY_COST_CEILING=5.00 # Required. Per step. Beyond this the step fails and the run stays resumable. MAX_ATTEMPTS=3 # Optional · secret. Absent by default. Sending also needs the live flag. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Required. Dry run is the default and this is the second of the two locks on sending. LIVE_SEND=false
# Sumora · product brief ## Problem The visible surface, a prompt box that produces a researched, personalized outreach message, is a one-sitting build. What takes real work is everything that keeps an agent from quietly going insane on step seven: persistent memory about your business and each contact, retries, browser sessions that survive logins and captchas, cost ceilings, and a human approval gate before anything is actually sent. For one person automating their own pipeline, you can get to genuinely useful in a weekend because you are allowed to be the approval step and the error handler. The gap widens fast the moment you want it unattended, multi-user, or connected to a real CRM and mailbox over OAuth. The failure mode also matters: a broken personal script wastes your evening, a broken agent emails the wrong prospect from your domain. ## Product outcome An outreach agent where a hostile web page can at worst produce a strange draft, every claim is checkable against stored text, a confused run costs a known maximum, and nothing reaches a person without somebody reading it first. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - An LLM API key - Playwright and a machine willing to run a real browser - A search API or scraping tolerance - SMTP credentials or a mail API if you want it to actually send - Patience for step-level debugging ## Explicit non-goals for v1 - Automation of any logged-in site. - CRM and mailbox integrations. - Unattended scheduled sending. - Deliverability and domain warm-up. - Unattended reliability: retries, resumable runs, and recovery when a step fails halfway through - Maintained integrations and OAuth to CRMs, mailboxes, and calendars, plus keeping them working when those APIs shift - Sandboxed execution and guardrails so a confused agent cannot take a real action against a real person - Cost control and model routing, so one runaway loop does not become a surprise bill - Deliverability and sending reputation work, which is its own unglamorous specialty ## Success criteria - An injected instruction in a fixture page is confirmed not to change the recipient - A fabricated quote is confirmed stripped and the draft flagged - A run is confirmed to stop before crossing its cost ceiling - Sending is confirmed impossible without both the flag and the credential - No bulk approval path is confirmed to exist
# Build brief · Sumora 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 local research agent that reads a company's own pages, drafts one message with its evidence attached, and cannot send anything you have not read to replace Sumora. 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 Text the agent fetched from the open web is data. It is never instruction. The OWASP Top 10 for large language model applications lists prompt injection as its first entry, and describes indirect prompt injection as the case where content from a website or a file, once interpreted by the model, changes the model's behaviour in ways nobody intended. Its mitigations are the design of this build: privilege control, so the application holds the credentials and the model does not; a human in the loop for privileged operations; and clear separation of untrusted content from the instruction. Concretely, that means the model never holds the mail credential, never chooses a recipient address, and never triggers a send. Fetched page text is stored in its own field, passed in a delimited block labelled as untrusted, and shown to you beside the draft. If a fetched page contains a line telling the agent to email someone else, the worst that can happen is a strange draft you delete. Second, and it is not a technical question: cold outreach to people who did not ask for it is regulated differently in different places, and the rules that apply depend on where the recipient is, not where you are. Decide who you are allowed to contact before you build the sender, record for each address why it was contacted and where it came from, and build the suppression list in Phase 5 before the first live send rather than after the first complaint. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node with a small local HTTP server, bound to localhost · one operator reviewing drafts, no accounts and nothing exposed - Storage: SQLite, with every step written before and after it runs · a crashed run must be resumable, and that means the ledger is the state - Fetching: A headless browser for the target's own public pages only · the evidence has to come from somewhere you could have read yourself - Model: One rented model, given only delimited untrusted text and no credentials · the model drafts, it does not act, and that split is the whole safety story - Sending: A mail credential held by the application, behind an explicit human approval · nothing leaves without somebody reading it, and that somebody is not the model ### Data model (create this before Phase 1) - business_context(id, body, updated_at) · your offer, tone and hard rules, edited by you and never by the model - targets(id, company, domain, contact_name, contact_email, source, source_note, added_at, suppressed_at) - runs(id, target_id, state, created_at, finished_at, cost_minor, failure_reason) · states are queued, running, needs_review, approved, sent, failed - steps(id, run_id, ordinal, tool, input_json, output_json, error, tokens_in, tokens_out, cost_minor, started_at, finished_at) - fetched_pages(id, run_id, url, fetched_at, http_status, content_sha256, text) · stored separately from anything the model is told to follow - claims(id, draft_id, text, page_id, span_start, span_end, verified) · a claim with no span is an unsupported claim - drafts(id, run_id, revision, subject, body, created_at, approved_at, approved_by, sent_at, message_id) - suppression(email_or_domain, reason, added_at) · checked before a run starts and again before a send ### Phase 1 · A run is a ledger, not a loop Build: Define the run as an ordered list of steps persisted in SQLite. Write the step row with its input before execution, complete it with output, cost and timing afterwards. A resume command finds runs left in running, replays from the first incomplete step, and never re-executes a completed one. Trap: Holding the run in memory and writing only the final result means a crash on step five throws away four paid model calls and a browser session, and because the retry looks identical you pay for them again. Persist before you execute, and make resume the normal way a run finishes rather than an error path you test once. Done when: Killing the process during step three and running resume completes the run without re-executing steps one and two, tested. Every step row has a started_at before any side effect and a finished_at or an error afterwards, asserted in the suite. A run can be replayed from its ledger to show exactly what it did and what each step cost. Two resume commands running at once do not both claim the same run, tested with parallel invocations. Do not build yet: No fetching, no model calls, no drafting, no sending. ### Phase 2 · Fetching is quarantine Build: Fetch the target's home page and one about or pricing page with a headless browser. Extract readable text, hash it, store it in fetched_pages. Respect the site's crawl directives and identify yourself in the user agent. Never follow links off the target's own domain. Pass page text to the model only inside a delimited block labelled as untrusted content that the model is told to summarise, not obey. Trap: Concatenating fetched text straight into the prompt means any page can rewrite your agent's instructions, and the injected line does not have to be visible to a human reader to be read by the model. Keep the fetched text in its own field, wrap it in delimiters when you send it, state in the instruction that its contents are data, and show the operator the exact text the model saw. Done when: The instruction and the fetched text are separate arguments in the code and are never string-concatenated, verified by review and by a test on the prompt builder. A fixture page containing an instruction to email a different address produces a draft addressed to the original target, tested. Fetching is limited to the target's own domain, tested with a fixture that links elsewhere. The stored text hash matches what the operator is shown next to the draft. Do not build yet: No drafting, no claims, no cost ceilings, no sending. ### Phase 3 · A draft that can point at its evidence Build: Ask the model for a short message plus a list of claims, each with the exact quoted substring it came from. Verify each quote appears in the stored page text at the offsets given. A claim that fails verification is stripped and the draft is marked unsupported. Show the operator each claim next to its highlighted source. Trap: Asking the model to cite its sources and trusting the citation is the same mistake as trusting the summary, because a fabricated quote is as easy to generate as a fabricated fact. Verify every quoted span against the stored text by string search, and treat a quote that is not found as a failure of the draft rather than a formatting problem to repair. Done when: A claim whose quote does not appear in the stored page text is removed and the draft is flagged, tested with a fabricated citation. Claim verification is a string search against stored text and never a second model call, verified by review. The review screen shows each surviving claim with its source text highlighted. A draft with no verified claims is refused rather than sent for approval. Do not build yet: No ceilings, no approval gate, no sending, no suppression. ### Phase 4 · Ceilings, backoff and giving up loudly Build: Per-run and per-day spend ceilings from the environment, checked before every model and fetch step. Exponential backoff with a small maximum attempt count, then mark the step failed and leave the run resumable. A run that exceeds its ceiling stops in failed with the reason recorded, and the daily ceiling blocks new runs rather than truncating one in progress. Trap: Retrying a model call that returned a malformed response, with no attempt cap, produces a loop that spends money at whatever rate the provider allows and leaves nothing to show for it. Cap attempts, check the ceiling before the call rather than after, and record a failed step rather than retrying past the cap. Done when: A run that would exceed its ceiling stops before making the call that would cross it, tested at the boundary with a stubbed price. Attempts are capped and the cap is asserted in the suite with a fake that always fails. The daily ceiling blocks new runs while allowing an in-progress run to finish, tested. Every failure records a readable reason on the run and appears on the dashboard rather than only in a log. Do not build yet: No approval gate, no sending, no suppression list. ### Phase 5 · The approval gate and the send Build: Dry run is the default and sending requires both a configured credential and an explicit flag. The approve action is a button on the review screen that records who approved and when. Check the suppression list before a run starts and again immediately before the send. Store the message id and keep the full sent body. Provide an unsubscribe route that writes to suppression. Trap: An approve-all control on a list view is the feature that turns a careful design into a mass mailer, because approving twenty drafts by their subject lines is not reading them. Approve one draft at a time from the screen that shows its evidence, and if you want volume, raise the daily run ceiling rather than removing the reading. Done when: No code path sends without a per-draft approval recorded with an actor and a timestamp, asserted in the suite. Sending is impossible without the live flag and a configured credential, tested with both missing. A suppressed address is refused both at run creation and immediately before the send, tested at each point. Every sent message stores its final body, its claims and its message id, and an unsubscribe request adds a suppression row that a later run honours. Do not build yet: Nothing after this. ### Out of scope (and why) - Automating any site that requires a login, which means handling somebody's credentials in a browser the model influences and is the fastest way to turn a drafting tool into an incident. - CRM and mailbox integrations over OAuth, which is a maintenance commitment to somebody else's release schedule rather than a weekend of work. - Unattended sending on a schedule, since the approval gate is the entire safety argument and removing it removes the reason this build is defensible. - Deliverability and domain warm-up, which is a specialty in its own right and cannot be faked by sending more slowly. ### README must contain - That fetched web text is treated as untrusted data and never as instruction, with the mitigation list it follows - That the model holds no credentials, chooses no recipients and triggers no sends - That every claim in a draft is verified against stored page text by string search, and what an unsupported draft looks like - Where the per-run and per-day spend ceilings are set and what happens when they are reached - That the operator is responsible for deciding who may be contacted, and how the suppression list is maintained
# Architecture · Sumora ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript on Node with a small local HTTP server, bound to localhost | one operator reviewing drafts, no accounts and nothing exposed | | Storage | SQLite, with every step written before and after it runs | a crashed run must be resumable, and that means the ledger is the state | | Fetching | A headless browser for the target's own public pages only | the evidence has to come from somewhere you could have read yourself | | Model | One rented model, given only delimited untrusted text and no credentials | the model drafts, it does not act, and that split is the whole safety story | | Sending | A mail credential held by the application, behind an explicit human approval | nothing leaves without somebody reading it, and that somebody is not the model | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ledger | Run and step persistence, resume and claiming. | Any store, provided the row is written before the side effect. | | Fetch | Browser capture, domain limits and text hashing. | Any fetcher, provided the text never enters the instruction position. | | Draft | Model calls, claims and span verification. | Any model, provided quotes are verified by search against stored text. | | Cost | Per-run and per-day ceilings and attempt caps. | Any accounting, provided the check happens before the call. | | Send | Approval records, suppression and message retention. | Not swappable for anything that can send without a per-draft approval. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `MODEL_API_KEY` · required, secret · The drafting model. Give it a provider-side spend limit as well. - `DATABASE_PATH` · required · Runs, steps, fetched pages, claims and drafts. - `RUN_COST_CEILING` · required · A single run stops before crossing this figure. - `DAILY_COST_CEILING` · required · New runs are blocked at this figure; a run in progress finishes. - `MAX_ATTEMPTS` · required · Per step. Beyond this the step fails and the run stays resumable. - `SMTP_URL` · optional, secret · Absent by default. Sending also needs the live flag. - `LIVE_SEND` · required · Dry run is the default and this is the second of the two locks on sending. ## 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 · Sumora product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript on Node with a small local HTTP server, bound to localhost, SQLite, with every step written before and after it runs, A headless browser for the target's own public pages only, One rented model, given only delimited untrusted text and no credentials, A mail credential held by the application, behind an explicit human approval. - 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 - Holding the run in memory and writing only the final result means a crash on step five throws away four paid model calls and a browser session, and because the retry looks identical you pay for them again. Persist before you execute, and make resume the normal way a run finishes rather than an error path you test once. - Concatenating fetched text straight into the prompt means any page can rewrite your agent's instructions, and the injected line does not have to be visible to a human reader to be read by the model. Keep the fetched text in its own field, wrap it in delimiters when you send it, state in the instruction that its contents are data, and show the operator the exact text the model saw. - Asking the model to cite its sources and trusting the citation is the same mistake as trusting the summary, because a fabricated quote is as easy to generate as a fabricated fact. Verify every quoted span against the stored text by string search, and treat a quote that is not found as a failure of the draft rather than a formatting problem to repair. - Retrying a model call that returned a malformed response, with no attempt cap, produces a loop that spends money at whatever rate the provider allows and leaves nothing to show for it. Cap attempts, check the ceiling before the call rather than after, and record a failed step rather than retrying past the cap. - An approve-all control on a list view is the feature that turns a careful design into a mass mailer, because approving twenty drafts by their subject lines is not reading them. Approve one draft at a time from the screen that shows its evidence, and if you want volume, raise the daily run ceiling rather than removing the reading.
# Delivery milestones · Sumora Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A run is a ledger, not a loop Every step is written before it runs and completed after, so a crash resumes instead of restarting. ### Steps 1. Write the step before you execute it The row is the only proof a paid call happened. Files: `src/run/ledger.ts`, `tests/resume.test.ts` 2. Make resume replay from the first incomplete step Restarting from the top is how you pay twice. Files: `src/run/resume.ts` 3. Claim a run before resuming it Two resumes on one run is two outreach emails. Files: `src/run/claim.ts` ### Done when - [ ] A killed run resumes without repeating steps - [ ] Every step has start and end or an error - [ ] A run can be replayed from its ledger - [ ] Concurrent resumes claim distinct runs ### Watch out - Holding the run in memory and writing only the final result means a crash on step five throws away four paid model calls and a browser session, and because the retry looks identical you pay for them again. Persist before you execute, and make resume the normal way a run finishes rather than an error path you test once. ## M2 · Fetching is quarantine Page text is captured, hashed and stored where nothing can mistake it for an instruction. ### Steps 1. Keep fetched text out of the instruction position Concatenation is the vulnerability, not the model. Files: `src/model/prompt.ts`, `tests/injection-fixture.test.ts` 2. Stay on the target's own domain and say who you are The evidence should be pages you could have opened yourself. Files: `src/fetch/browser.ts` 3. Hash and store exactly what the model was given So the operator reviews the same text the model read. Files: `src/fetch/store.ts` ### Done when - [ ] Instruction and page text are never concatenated - [ ] An injected instruction does not change the recipient - [ ] Fetching stays on the target's domain - [ ] Stored hash matches what is displayed ### Watch out - Concatenating fetched text straight into the prompt means any page can rewrite your agent's instructions, and the injected line does not have to be visible to a human reader to be read by the model. Keep the fetched text in its own field, wrap it in delimiters when you send it, state in the instruction that its contents are data, and show the operator the exact text the model saw. ## M3 · A draft that can point at its evidence Every factual claim in the message maps to a span of fetched text, and one that does not blocks the draft. ### Steps 1. Ask for claims with exact quoted spans A quote is checkable; a summary is not. Files: `src/model/draft.ts` 2. Verify quotes by search, never by asking again A second model call verifies nothing. Files: `src/claims/verify.ts`, `tests/verify.test.ts` 3. Highlight the source beside each claim Reviewing is only fast when the evidence is adjacent. Files: `src/web/review.ts` ### Done when - [ ] A fabricated quote is stripped and flagged - [ ] Verification is a string search, not a model call - [ ] Claims display with highlighted sources - [ ] A draft with no verified claims is refused ### Watch out - Asking the model to cite its sources and trusting the citation is the same mistake as trusting the summary, because a fabricated quote is as easy to generate as a fabricated fact. Verify every quoted span against the stored text by string search, and treat a quote that is not found as a failure of the draft rather than a formatting problem to repair. ## M4 · Ceilings, backoff and giving up loudly A confused run costs a known maximum and stops in a state you can see. ### Steps 1. Check the ceiling before the call, not after Afterwards is one call too late, every time. Files: `src/cost/ceiling.ts`, `tests/ceiling.test.ts` 2. Cap attempts and record the failure An uncapped retry is an uncapped bill. Files: `src/run/retry.ts` 3. Put failure reasons on the dashboard A log file is where failures go to be ignored. Files: `src/web/dashboard.ts` ### Done when - [ ] A run stops before crossing its ceiling - [ ] Attempts are capped and tested - [ ] The daily ceiling blocks new runs only - [ ] Failure reasons are visible in the interface ### Watch out - Retrying a model call that returned a malformed response, with no attempt cap, produces a loop that spends money at whatever rate the provider allows and leaves nothing to show for it. Cap attempts, check the ceiling before the call rather than after, and record a failed step rather than retrying past the cap. ## M5 · The approval gate and the send (production only) Nothing reaches a real person without a human reading it, and the record of why survives. ### Steps 1. Approve one draft at a time, from its evidence Batch approval is not review, whatever the button says. Files: `src/web/approve.ts`, `tests/no-bulk-approve.test.ts` 2. Check suppression twice, at both ends of the run The list can change while a run is in flight. Files: `src/suppression/check.ts` 3. Store the sent body, not a template reference You will need to know exactly what they received. Files: `src/send/record.ts` ### Done when - [ ] Every send has a recorded per-draft approval - [ ] Sending needs both the flag and the credential - [ ] Suppression is checked at start and at send - [ ] Sent bodies and message ids are retained ### Watch out - An approve-all control on a list view is the feature that turns a careful design into a mass mailer, because approving twenty drafts by their subject lines is not reading them. Approve one draft at a time from the screen that shows its evidence, and if you want volume, raise the daily run ceiling rather than removing the reading.
# Operations · Sumora ## Backup The SQLite file, which holds the runs, the fetched evidence and every sent body. The sent bodies are the part you cannot reconstruct and the part you will be asked about. ## Restore Restore, run resume, and confirm that no run in the restored database is in approved but unsent. That state after a restore is the one that can send something twice. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Daily spend against the ceiling, runs sitting in needs_review, the rate of drafts refused for unsupported claims, and any run that resumed more than once. A rising refusal rate usually means the fetch step is returning navigation text rather than content. ## Incident checklist The incident is a message that should not have gone out. The ledger, the stored page text, the verified claims and the approval record together reconstruct what the operator saw at the moment they approved, which is the only account worth having. 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 - [ ] An injected instruction in a fixture page is confirmed not to change the recipient - [ ] A fabricated quote is confirmed stripped and the draft flagged - [ ] A run is confirmed to stop before crossing its cost ceiling - [ ] Sending is confirmed impossible without both the flag and the credential - [ ] No bulk approval path is confirmed to exist ## Launch constraint Do not market omitted Sumora 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 · secret. The drafting model. Give it a provider-side spend limit as well. MODEL_API_KEY=sk-...redacted # Required. Runs, steps, fetched pages, claims and drafts. DATABASE_PATH=./data/agent.db # Required. A single run stops before crossing this figure. RUN_COST_CEILING=0.50 # Required. New runs are blocked at this figure; a run in progress finishes. DAILY_COST_CEILING=5.00 # Required. Per step. Beyond this the step fails and the run stays resumable. MAX_ATTEMPTS=3 # Optional · secret. Absent by default. Sending also needs the live flag. SMTP_URL=smtps://user:redacted@mail.example.com:465 # Required. Dry run is the default and this is the second of the two locks on sending. LIVE_SEND=false
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because the demo is the easy 20 percent and the remaining 80 percent is failure handling nobody enjoys writing. A business paying for this is buying the assumption that the agent will not send the wrong message to the wrong contact at 2am, and that when something does break, someone else's on-call rotation deals with it. A solo operator who is happy to review every draft can absolutely self-host their version and get most of the leverage. A team of ten with a shared pipeline and a reputation to protect generally decides that the subscription is cheaper than owning an agent orchestration layer.
xUnattended reliability: retries, resumable runs, and recovery when a step fails halfway through
xMaintained integrations and OAuth to CRMs, mailboxes, and calendars, plus keeping them working when those APIs shift
xSandboxed execution and guardrails so a confused agent cannot take a real action against a real person
xCost control and model routing, so one runaway loop does not become a surprise bill
xDeliverability and sending reputation work, which is its own unglamorous specialty
Nothing worth pointing at. That's why the prompt exists.
Vibecode Sumora
Kinda. The core of Sumora is buildable in a weekend with the prompt on this page, but there are real gaps: Unattended reliability: retries, resumable runs, and recovery when a step fails halfway through, Maintained integrations and OAuth to CRMs, mailboxes, and calendars, plus keeping them working when those APIs shift. Read the honest list above before committing.
How much does Sumora cost?
Sumora costs about $79.99/month (Starter, checked 2026-08-18), which is $959.8799999999999 per year.
What do I lose by replacing Sumora?
Honestly: Unattended reliability: retries, resumable runs, and recovery when a step fails halfway through; Maintained integrations and OAuth to CRMs, mailboxes, and calendars, plus keeping them working when those APIs shift; Sandboxed execution and guardrails so a confused agent cannot take a real action against a real person; Cost control and model routing, so one runaway loop does not become a surprise bill; Deliverability and sending reputation work, which is its own unglamorous specialty. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Sumora?
No mature open-source alternative worth pointing at, which is exactly why the one-shot prompt on this page exists.