Spiral Architecture
System design + data model. For operating instructions see runbook.md.
Four-skill pipeline
Source of truth
Google Sheets is canonical. Every state transition writes a Sheets column with a timestamp. HubSpot is temporary — records exist only when client action is needed, and are deleted after the action completes.
Tiebreakers when systems disagree:
- Sheets vs HubSpot → Sheets wins
- Sheets vs Instantly (about lead existence) → Instantly wins (it's the side that actually sends mail)
Lead state machine
Side branches:
- Any step can transition to
errorwitherror_reasonpopulated. The row stays put; rerunning a skill ignoreserrorrows by default. To retry, setprocessing_statusback topendingand clearerror_reason(see runbook). - Replies classified as
Not Interested,Wrong Person,OOOland in the terminallogged_onlystate. They're logged inRepliesbut no draft is written and nothing goes to HubSpot.
Sheet tabs
| Tab | Role |
|---|---|
Leads | Master lead DB. Status columns track every step. |
Replies | Every reply received + classification + draft. |
Sent_Drafts | Audit log of every approved reply sent back. |
Audit_Log | Every skill action with timestamp + lead_id. |
Config | Key-value: daily_reply_last_run_at, etc. |
See sheet_schema.md for column-by-column reference.
Column naming convention
The Leads tab mixes two naming styles by design:
- Instantly's exact column names for fields that ride into Instantly as custom variables. These keep Instantly's casing (
firstName,companyName,icp_type,activity,cadence,signal_fact,vertical,matched_case,case_link,opener,bridge,proof,asset_line,fu1_line–fu3_line,breakup_line,cta,cta_soft,subject_t1–subject_t5). Defined insheet_schema.INSTANTLY_COLUMNS. - snake_case for Apollo enrichment + Spiral pipeline state (
apollo_contact_id,last_name,domain,industry,signals,processing_status,instantly_lead_id,pushed_to_instantly_at,loop_status, etc.).
The push script (push_to_instantly.py) does two renames at the API boundary:
firstName→first_name,companyName→company_name(Instantly's REST API expects snake_case at the top level).titleandlinkedin_urlare skipped from custom_variables — Instantly already exposes them as built-in lead columns, and pushing them as custom vars would create duplicate columns in the workspace.
Routing key: {icp_type_lowercased}_{tier} → Instantly campaign UUID (e.g. dream_T1 → 8a8f1ff1-…). 9 keys total (3 ICP × 3 tier — T1, T2, T3), defined in references/campaigns.json.
Proof line (case study) lookup
The proof Instantly column is composed against two lookup keys:
matched_case— the case study name to reference (today, a constantAalo Atomics).case_link— the URL of that case study (today,https://www.spiralstudios.io/work/aalo).
Both are persisted on the lead row and pushed as Instantly custom variables. The email template references {{case_link}} as its own placeholder. Don't embed the URL inside the proof text — Instantly templates already have access to {{case_link}}. When you add a second case study, add a vertical → case mapping table to email_templates.md.
Voice guide enforcement
voice_guide.md is loaded fresh on every drafting run. Hard rules enforced today:
- No em-dashes (—) anywhere in generated copy. AI tell. Use periods, commas, or new sentences.
- No buzzwords / startup-speak. Concrete blocklist: "inflection", "GTM motion", "playbook", "design-partner mapping", "compound", "unlock", "scale-up runway", "ecosystem", "ramp", "leverage", "synergy", "deep dive", "move the needle", "stakeholders", "alignment", "thought leadership", "operationalize", "ideate", "value-add".
- Never quote a price, never commit a delivery date, never use "guarantee", never disparage a competitor by name.
The drafting flow includes an em-dash + buzzword sweep before the final voice-guide check (per email_templates.md step 7).
Idempotency strategy
| Skill | Skip condition |
|---|---|
spiral-ingest-leads | Row's apollo_contact_id already in Leads tab |
spiral-weekly-enrichment | processing_status is not pending or empty (so enriched, pushed, error, etc. are skipped) |
spiral-daily-replies | Reply already in Replies tab (by instantly_reply_uuid) |
spiral-sync-approvals | Lead row has sent_back_to_instantly_at populated |
All four are safe to re-run on the same input.
Rate limit ceilings (10-20% under hard limits)
| API | Limit |
|---|---|
| Instantly general | 90 rps |
Instantly /emails list | 18 rpm |
| Apollo | 50 rpm |
| HubSpot | 8 rps |
| Sheets | 50 rpm |
Token-bucket limiter in rate_limit.py blocks until tokens are available. Retries via tenacity (3 attempts, exponential backoff) handle 429s as a second layer.
Loop boundary
Once a reply is approved and sent, loop_status='reply_sent' and the lead is terminal. Subsequent replies in the same thread are not auto-processed — they show up in Instantly Unibox for manual handling. This is intentional: the system mediates the first exchange (cold → reply → approved response). Anything beyond that is a conversation, not a sequence.
What's deliberately not here
- Webhooks — polling via skill triggers is simpler and Growth-plan friendly. Daily/weekly cadence is enough.
- Background workers — Claude Code session is the runtime. No daemons.
- A reconciler — every skill run reconciles by definition (re-reads Sheets, dedupes by id).
- A separate Anthropic API key — Claude inside Claude Code does all drafting. No second model dependency.
- Auto-send of cold emails — the Instantly UI gate is intentional. Human reads every draft before it goes out.