It is a state machine
A deterministic owner of state and sequencing. It ingests evidence, selects pointers, assembles a cadence, previews it, creates campaigns, polls the tools, enforces suppression and routes human tasks.
A stateful, multi-stage outreach pipeline: it ingests a company dossier and a chosen angle, assembles a previewed email cadence and LinkedIn sequence, creates campaigns without launching, then polls the sending tools and enforces suppression across both channels. The engine owns state, sequencing and safety. The LLM only writes copy. The tools do the sending.
Three cards keep the scope honest. If a request pushes work into the right-hand card, it is out of scope for v1.
A deterministic owner of state and sequencing. It ingests evidence, selects pointers, assembles a cadence, previews it, creates campaigns, polls the tools, enforces suppression and routes human tasks.
It never sends without approval, never writes replies, is not a CRM and is not a full web app in v1. HubSpot stays the CRM; the Context Archive is read-only history.
Phase 0 is nearly done: company registry, domains, versioned dossiers, Context Archive sync, audit log, a stdlib control site and Apollo enrichment. Angles, planning and sends are next.
Evidence flows into one engine process backed by SQLite. The engine talks to the sending tools and reads their state back; it never assumes a send happened.
Ingress, engine and tools each have one job. v1 needs no public ingress because everything is polled; webhooks are an optional later fast path.
Each stage is a command the runner can call and a row or table it can write. Stages 1–3 exist today; enrichment is partial; the rest is the build ahead.
One SQLite database is the single source of truth. It is gitignored, backed up nightly, and grows additively as phases land.
| Table | Key | Holds | Status |
|---|---|---|---|
meta | key | Schema version and engine metadata. | built |
companies | uid | One row per company: name, primary domain, country, motion, uid mode. | built |
domains | domain | Every domain or alias mapped to exactly one UID; is_primary marks the entered domain. | built |
dossiers | (uid, version) | Versioned JSON evidence for the company. Re-import bumps the version. | built |
context_accounts | uid | Raw account summary, including the legacy guardrail block that is stored but ignored. | built |
context_events | event_id | Archive events upserted on id; incremental cursor per UID. | built |
local_events | auto | Append-only audit log: company created, domain linked, dossier set, schema migrated. | built |
people | person_id | Humans keyed by normalized email and LinkedIn URL; history survives a company change. | planned |
campaigns | campaign_id | Angle and sequence pins, scope, channel, external ids per tool. | planned |
leads | (campaign_id, person_id) | Membership, step, send state, suppression state, per-lead variables. | planned |
tasks | task_id | Manual steps and reviews: company, person, campaign, step, due date, payload, allowed actions. | planned |
assets | asset_id | Intel registry: kind, path or URL, checksum, approved. Per company, not per lead. | planned |
Companies are anchored to domains, never names. People are anchored to both email and LinkedIn so either channel can stop the other.
| Company UID | |
|---|---|
| v1 provisional | TMP-000001, sequential, minted in one transaction. Human-readable, never external truth. |
| v2 permanent | co_ plus the first ten characters of a base32 hash of the primary domain, stored in a column so it can migrate. |
| Anchor | The domain entered into the engine stays primary. Archive domains attach as aliases and never rename the company. |
| Conflict | A domain owned by another UID is reported, never silently moved. Merge is a deliberate command. |
| Person | |
|---|---|
| Key | Normalized email and LinkedIn URL, both nullable but at least one required. |
| Link | Belongs to a company UID but keeps history if the company changes. |
| Stop | A meeting or reply on either channel stops both channels for that person. |
| Erasure | Delete the person; tombstone the email in a blocklist so it can never be re-added. |
lowercase → strip scheme, path, port, userinfo, leading "www.", trailing dot reject anything that is not [a-z0-9.-]+ containing a dot one domain → exactly one UID
Campaigns are shared per angle × variant × channel × EU split. Company-specific content is injected as per-lead variables and tags, not by cloning campaigns.
oe:{angle}:{channel}:{variant}:{scope}
oe:noa-1:email:ev1:global
oe:noa-1:email:ev1:eu # EU-HQ companies
oe:noa-2:linkedin:lv1:global
Today’s catalog fits in roughly 16 campaigns (2 angles × 2 EU splits × 4 variants), not one per company. A company is a tag or list plus custom variables.
{
"id": "email-v1", "version": 1,
"channel": "email", "tool": "instantly",
"delay_unit": "calendar_days",
"steps": [
{ "n": 1, "action": "auto", "template": "pitch", "delay_days": 0 },
{ "n": 2, "action": "auto", "template": "offer_intel", "delay_days": 2 },
{ "n": 3, "action": "auto", "template": "send_intel", "delay_days": 1,
"requires": ["intel_asset"] },
{ "n": 4, "action": "auto", "template": "drop_off", "delay_days": 3 }
],
"stop_on": ["reply", "meeting", "bounce", "unsubscribe"]
}
| Rule | Behaviour |
|---|---|
| Delays | Count from the previous step’s send for that lead, in US business days. US federal holidays are skipped. |
| Manual actions | action: manual creates a task and does not advance the lead until confirmed. |
requires | A hard gate. An automated step needing intel cannot activate until the asset exists and is approved. |
| Version pins | A campaign stores angle id and version plus sequence id and version. Editing an angle never mutates an in-flight campaign. |
| Pacing | Eight leads per company per day, no total cap. Stop only on lead exhaustion, reply, meeting, pause or campaign end. |
| Variant choice | Angles are saved; a campaign may also use a one-off angle. Reusing the same company within 60 days warns and needs a written reason. |
Actions are executed in the tools first, then recorded locally. Every action is idempotent. Legacy archive guardrails are ignored and stored only.
| Signal | Source | Action |
|---|---|---|
reply_received | Instantly | Classify; stop the lead on both channels; create a review task. |
lead_interested | Instantly | Stop both channels; positive-response row; task. |
lead_not_interested / neutral | Instantly | Stop the lead on both channels. |
auto_reply_received | Instantly | Stop; pause if a return date is known. |
lead_out_of_office | Instantly | Pause; resume task after the return date. |
email_bounced | Instantly | Stop; delete from campaign; invalidate and blocklist the email; try one alternate. |
lead_unsubscribed | Instantly | Stop; global blocklist; never re-add. |
lead_wrong_person | Instantly | Stop; flag for data quality. |
lead_meeting_booked | Instantly | Company-level stop: pause that company’s campaigns; if shared, delete all its leads; stop both channels. |
| HeyReach reply / connection reply | HeyReach | Stop that lead on both channels; create a review task. |
| Meeting · any channel | Manual command or Telegram button | Same company-level stop. |
Context declined / excluded | Context API · legacy | Ignored by default — stored raw, never a gate. |
Every manual step is a first-class object. A manual action blocks the next step until a human confirms it.
States: pending → in_progress → done | cancelled, plus snoozed and overdue.
| Type | Purpose |
|---|---|
create_intel | Produce a one-pager, demo video, intel image or table. |
send_linkedin_manual | Manual LinkedIn step; the lead does not advance until confirmed. |
confirm_manual_send | The only way a manual step advances. |
review_reply | Classify and answer a reply; sequences already stopped. |
review_draft | Approve generated copy before push. |
fix_enrichment | Missing or ambiguous email or identity. |
enforce_suppression | Manual stop that must be applied in a tool. |
resume_ooo | Resume after a return date. |
Intel is produced and approved by a human. A sequence with requires: ["intel_asset"] cannot activate until the asset exists.
One engine, four ways to touch it. The engine is always the single writer; surfaces call it, never write state directly.
| Surface | What it does | Status |
|---|---|---|
| CLI / runner | JSON on stdout, errors on stderr. Commands cover init, UID mint, status, company add / link-domain / list / show, dossier set / show, and context sync / show. | built |
| Control site | Server-rendered dashboard, companies, company detail, angles, campaigns and settings. Basic auth, CSRF, loopback by default. Reads SQLite; writes through the engine CLI. | built |
| Telegram | Long polling. Task notifications with inline confirm and review buttons. No public URL needed. | planned |
| Status surface | One page refreshed from the tools: key validity, campaign counts, bounce rate, last poll, oldest unprocessed event, overdue tasks and spend versus cap. | planned |
{
"name": "outreach-engine",
"entry": ["{python}", "run.py"],
"output": "json",
"commands": { "company add": { "costs": null, "writes": "state" }, … },
"state": { "path": "state/outreach.db", "idempotent": true },
"secrets": ["CONTEXT_API_KEY", "INSTANTLY_API_KEY", "HEYREACH_API_KEY", "NOTION_TOKEN", …],
"ports": { "site": { "bind": "127.0.0.1", "port": 8787 } }
}
One always-on VM runs the engine and cron. This architecture page is hosted separately on Cloudflare Pages, with the copilot as a serverless function.
cron */5 ──▶ engine poll / drain / tick
│
├─ SQLite ledger + state machine
├─ run artifacts (pruned)
├─ Instantly ◀──▶ create + poll
├─ HeyReach ◀──▶ create + poll
├─ Context Archive (read-only)
├─ Notion (intake)
└─ Telegram (long polling)
public demo:
Cloudflare Pages ── static page
└─ /api/chat ──▶ LLM provider
Smallest end-to-end path first. Each phase has a definition of done; nothing advances on intent alone.
Companies, domains, dossiers, Context Archive sync, audit log, tests and control site. Done when the engine check passes and tests are green.
nearly doneNotion import, archive intake, angle load, sequence plan, message preview, export JSON and CSV. Still no network writes.
plannedCreate campaign, add leads, never activate without an explicit command; ingest events; execute stops; kill switch. Done when a simulated reply removes a lead within ten minutes.
plannedSequences and lists, manual-step gating, confirm buttons, cross-channel stop. Done when a manual LinkedIn step cannot advance without confirmation.
plannedDashboard, reconciliation and status. Done when the engine survives the laptop being closed and the dashboard matches the database.
plannedAutonomous sending, reply writing, CRM replacement, a full web app, cold calling and webhook ingress. Revisit only after the email and LinkedIn path is stable.
laterThese are the rules that keep an efficient pipeline from becoming an unsafe one.
Preview shows every step, resolved variables, limits and any pointer dropped for missing evidence. v1 creates campaigns but never launches.
Every pointer cites a dossier path or proof id. Missing evidence means the pointer is dropped, not invented. A digit and entity validator rejects surprises.
A reply or meeting on one channel stops both. A meeting pauses all of that company’s active campaigns, across angles.
The engine never assumes a send happened. It creates, then reads state back. External ids are stored so re-runs upsert.
Run artifacts go to the engine’s own run folder; persistent state goes to the state folder. Nothing writes next to the user’s source files.
Company HQ in the EU, EEA or UK routes to the EU campaign. Deanonymization-derived pointers are dropped for EU or unknown visitors. Unknown fails closed for the pointer, not the campaign.
This page is a public reading aid. When it disagrees with the engine docs, the docs win and this page is fixed in the same commit. The repository link will be added after v1 ships.
| Document | Owns |
|---|---|
DESIGN.md | Decisions log, data model, event-to-action table and open questions. |
README.md | How to run, state schema, control site, tests and docs index. |
engine.json | The machine contract: commands, costs, state, secrets and ports. |
ANGLE-SPEC.md | Angle questionnaire and pointer / evidence rules. |
NOTION-INTAKE.md | Notion template contract and intake system. |
OPEN-TASKS.md | Human tasks, credentials and blockers. |