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:

Lead state machine

Side branches:

Sheet tabs

TabRole
LeadsMaster lead DB. Status columns track every step.
RepliesEvery reply received + classification + draft.
Sent_DraftsAudit log of every approved reply sent back.
Audit_LogEvery skill action with timestamp + lead_id.
ConfigKey-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:

The push script (push_to_instantly.py) does two renames at the API boundary:

Routing key: {icp_type_lowercased}_{tier} → Instantly campaign UUID (e.g. dream_T18a8f1ff1-…). 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:

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:

The drafting flow includes an em-dash + buzzword sweep before the final voice-guide check (per email_templates.md step 7).

Idempotency strategy

SkillSkip condition
spiral-ingest-leadsRow's apollo_contact_id already in Leads tab
spiral-weekly-enrichmentprocessing_status is not pending or empty (so enriched, pushed, error, etc. are skipped)
spiral-daily-repliesReply already in Replies tab (by instantly_reply_uuid)
spiral-sync-approvalsLead 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)

APILimit
Instantly general90 rps
Instantly /emails list18 rpm
Apollo50 rpm
HubSpot8 rps
Sheets50 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

  Have your own?

Paste your markdown, get a link like this.

Write your own