Runbook
The precise operator playbook. If you only read one doc in spiral-shared/, read this one.
TL;DR
| When | What | Command |
|---|---|---|
| Monday morning | Pull new leads in | drop CSV → /spiral-ingest-leads |
| Monday morning | Enrich + push to Instantly | /spiral-weekly-enrichment |
| Tue–Fri morning | Process new replies | /spiral-daily-replies |
| Anytime client approves | Send approved drafts | /spiral-sync-approvals |
Everything is idempotent and re-runnable. If a skill crashes, just run it again.
One-time setup
Run these once, in order. Everything after assumes they're done.
1. Python env + secrets
cd ~/.claude/skills/spiral-shared
python3 -m venv .venv
source .venv/bin/activate
pip install -r scripts/requirements.txt
cp .env.example .env # then fill in the secrets
Place your Google service-account JSON at spiral-shared/google-service-account.json. Both files are gitignored.
2. Sanity check connectivity
python scripts/healthcheck.py
All five lines (Apollo, Instantly, HubSpot, Sheets, env) must be OK before proceeding.
3. Bootstrap the Google Sheet
python scripts/bootstrap_sheet.py
Creates Leads, Replies, Sent_Drafts, Audit_Log, Config tabs with the current schema. Re-run any time sheet_schema.py changes — it syncs the header row in row 1.
Schema changes are wipe-and-rebuild.
bootstrap_sheet.pyonly resyncs the header row; existing data rows stay put and will mis-align with the new column order. If you renamed/reordered columns insheet_schema.py, also clear the data rows (rows 2+) on theLeadstab — easiest viaws.batch_clear(['A2:BU<last>'])in a one-off script, then re-ingest from the source CSV.
4. Bootstrap HubSpot's Pending_Approval object
python scripts/bootstrap_hubspot.py
Creates the custom object schema for client approvals. Copy the printed object type ID into .env as HUBSPOT_APPROVAL_OBJECT_TYPE.
5. Fill in campaign UUIDs
Open references/campaigns.json. Replace every REPLACE_WITH_INSTANTLY_CAMPAIGN_UUID with the real Instantly campaign ID for that {icp_type_lowercased}_{tier} segment (5 keys total — C1 through C5: dream_T1, normal_T2, normal_T3, lookalike_T2, lookalike_T3). /spiral-weekly-enrichment will mark every lead as error if the matching segment's UUID is still a placeholder.
Valid segment matrix (5 total):
Code ICP Tier Campaign key C1 Dream T1 dream_T1C2 Normal T2 normal_T2C3 Normal T3 normal_T3C4 Lookalike T2 lookalike_T2C5 Lookalike T3 lookalike_T3Dream is T1-only. Normal and Lookalike skip T1.
enrich_one.pyclamps invalid combos (Dream → forces T1; Normal/Lookalike + T1 → demotes to T2) and logs the override inAudit_Log.
Find the UUIDs in the Instantly UI: each campaign's page URL contains its UUID, or call GET /api/v2/campaigns.
Weekly flow — Monday lead acquisition
Step 1 — Tag your search results in Apollo
In the Apollo UI, add every contact in your target search to two lists:
icp_type:<dream|normal|lookalike>(legacyset:Xis also parsed;set:activemaps toicp_type=Normal+activity=active)tier:<T1|T2|T3>(funding-based — see mapping below; bare digits liketier:1are accepted and auto-normalized to T-prefix)
The CSV's Lists column will carry "icp_type:dream, tier:T1" and the ingest script parses both. Why this matters: untagged rows land in segment ?_? and /spiral-weekly-enrichment cannot route them to a campaign — they end up marked error: missing icp_type or tier.
Tier mapping (funding stage → tier):
| Tier | Funding stage | Total raised | Meaning |
|---|---|---|---|
| T1 | Series C+ / IPO | ≥ $50M | premium, well-funded |
| T2 | Series A / Series B | $5M – $50M | mid |
| T3 | Seed / Pre-seed / Grant | < $5M | early |
If the operator leaves the Apollo tier: tag empty, enrich_one.py auto-derives the tier from Apollo's funding data using this mapping. Operator override is respected initially, but the clamp step then applies the matrix constraint above (Dream → T1, Normal/Lookalike + T1 → T2) — so a manual tier:T1 on a Normal lead will still be demoted. Both the auto-derive and the clamp log to Audit_Log.
Shortcut if your search is uniform (one icp_type + tier for the whole export): skip tagging and pass
--default-icp X --default-tier Nto ingest_csv.py. The/spiral-ingest-leadsskill will offer this when it sees a batch of?_?rows in dry-run.
Step 2 — Export from Apollo
Export your tagged search as CSV. Save it as apollo_to_spreadsheet.csv and drop it in:
~/.claude/skills/spiral-shared/inbox/apollo_to_spreadsheet.csv
(That folder is gitignored — lead data never gets committed.)
Step 3 — Ingest
/spiral-ingest-leads
The skill will:
- Dry-run first → preview counts per
{icp_type}_{tier}segment, surface any?_?problem - After you approve, run the real ingest → appends new rows with
processing_status=pending, dedupes onapollo_contact_id(so re-runs of the same CSV are safe) - Archive the file to
apollo_to_spreadsheet.<UTC-timestamp>.processed.csvso the next export can reuse the filename
Verify before moving on: open Google Sheets, scroll to the bottom of Leads, check the new rows have icp_type, tier, email, companyName, domain filled and processing_status=pending.
Step 4 — Enrich + push
/spiral-weekly-enrichment
What happens:
- Select batch — next 100
pendingrows, sorted:activity=activefirst, thenicp_typepriority (Dream > Normal > Lookalike), then row order. - Apollo enrich per row — refreshes title/LinkedIn/seniority, pulls org-level signals (funding round, hiring, intent), seeds
signalsandvertical. - Claude does internet research per lead, sets
activity(active/passive) + composessignal_fact(copy-ready trigger prose). - Claude writes the per-touch Instantly variables (
opener,bridge,proof,asset_line,fu1_line–fu3_line,breakup_line,cta,cta_soft,subject_t1–subject_t5) followingvoice_guide.md+ the lead's enriched context. - Push to Instantly as drafts in the matching
{icp_type}_{tier}campaign.
Verify before moving on: open Instantly, go to each campaign, check the drafts look right. Variables should reference real details about the lead/company — generic-sounding drafts mean Claude didn't have enough enrichment context.
Step 5 — Manual review in Instantly
Open each draft in the Instantly UI. Read each one. If the copy is good, send manually. Spiral does not auto-send the cold email — human approval is the gate.
Why this gate matters: even with good drafts, your sender reputation depends on not blasting 100 marginal emails in one go. Read them. Cull the weak ones. Send.
Step 6 — Re-pushing leads after edits
When you tweak the copy on a lead that's already been pushed (or just delete a lead in Instantly to send a corrected version), the default push flow will skip them as workspace duplicates. Two-step workaround:
- Delete the leads from Instantly (workspace, not just campaign — the delete must remove them from the whole workspace).
- Reset processing state in the sheet:
sheets_io.update_row("Leads", rn, { "processing_status": "enriched", "instantly_lead_id": "", "pushed_to_instantly_at": "", "loop_status": "none", }) - Re-run with
--force:python ~/.claude/skills/spiral-weekly-enrichment/scripts/push_to_instantly.py --force
--force flips skip_if_in_workspace to false, so Instantly accepts the lead even if a stale copy is still in the workspace cache. Use only when you've confirmed the leads are actually deleted (or you'll double-write).
Daily flow — Tue–Fri replies
Morning (once)
/spiral-daily-replies
What happens:
- Fetches all new replies from Instantly since
daily_reply_last_run_at(inConfigtab). - Classifies each by Instantly's
i_status:- Interested / Meeting Booked / Neutral → Claude drafts a reply (voice-guide rules, ≤120 words, never quote price, never commit dates, never use "guarantee")
- OOO / Not Interested / Wrong Person → log only, no draft
- Alerts on Interested + Meeting Booked with a visible block — these are positive handoffs, the client (and you) probably want to follow up out-of-band too.
- Pushes all drafts as HubSpot
Pending_Approvalrecords for client review.
Verify: check HubSpot's Pending_Approval list. Each new draft should be there with approval_status=pending. Confirm the client knows to review.
As soon as the client approves drafts
/spiral-sync-approvals
What happens:
- Pulls all HubSpot records with
approval_status=approved. - For each:
- Writes the approved text into the
Leadsrow (approved_at,approved_reply_text) - Sends via Instantly using the original
reply_to_uuid— the reply threads natively in the lead's Unibox conversation - Logs to
Sent_Drafts(audit trail) - Deletes the HubSpot record (so the client's view stays clean — only un-acted items visible)
- Writes the approved text into the
Idempotent. Run it as often as you like — multiple times a day is fine.
If the client edited the draft
The system reads whatever is in the HubSpot draft_text field at approval time. If they edited and approved, the edited text is what gets sent. No special handling needed.
If the client rejected the draft
The HubSpot record stays. To re-draft:
- Delete the HubSpot record manually
- Clear
sent_to_hubspotandhubspot_record_idfor that row in theRepliestab - Re-run
/spiral-daily-replies— the reply will be re-drafted
Best practices for high reply rates
- Always dry-run ingest first —
/spiral-ingest-leadsruns--dry-runautomatically and previews segment counts. Catch missingicp_type/tiertags before they hit the sheet. - Keep batch sizes ≤100/week during warm-up. Instantly's sender reputation is more fragile than the volume cap suggests.
- Tag Apollo lists religiously. A clean
{icp_type}_{tier}distribution means each lead routes to the right campaign with the right copy template. - Read every draft before sending. Both cold outreach (Instantly UI) and reply drafts (HubSpot). The pipeline writes good drafts most of the time, but you're the quality gate.
- Edit the voice guide when you notice patterns. If Claude keeps writing a phrase you hate, add it to
voice_guide.md. The guide is loaded every drafting run. Current hardening: identity-first (write as Rahul, deep tech founder, in first person), no em-dashes (—), no ellipses ("..."), expanded buzzword blocklist (incl. classics like "innovative", "cutting-edge", "visionary", "scalable"), banned openers (Came across,Been following,Your angle), banned CTA closers (Just say the word,Worth a call?— ask softly withIs it worth a quick 15 minutes?). - Watch the audit log.
Audit_Logtab logs every action. A spike inerrorrows is the earliest signal that something's wrong. - Fill
activitycorrectly. The select_batch step prioritizesactivity=active. Garbage in here means low-intent leads jump the queue.
Resetting a lead
To force one lead back through the pipeline:
- In
Leads, setprocessing_statusback topending(or empty). - Clear:
error_reason,enriched_at,signals,signal_fact,activity,opener,bridge,proof,asset_line,fu1_line,fu2_line,fu3_line,breakup_line,cta,cta_soft,subject_t1–subject_t5. - Run
/spiral-weekly-enrichment— the lead gets picked up in the next batch.
Re-importing a CSV after a failed ingest
/spiral-ingest-leads archives the source CSV to *.processed.csv only on a successful run with ingested > 0. If the run errored, the original file stays. Fix the issue and re-run — the dedupe on apollo_contact_id ensures no double-inserts.
To re-process an already-archived CSV: rename it back to apollo_to_spreadsheet.csv (drop the .processed.csv suffix and timestamp) and re-run.
Quotas to watch
| Resource | Soft threshold | Where to check |
|---|---|---|
| Instantly emails/mo | 80% of plan cap | Instantly billing page |
| Instantly uploaded contacts | 80% of 1,000 (Growth) | Instantly campaign view |
| Apollo credits | 20% remaining | Apollo billing |
| HubSpot daily API calls | check usage page | HubSpot settings |
| Google Sheets API | 60 req/min/user | Cloud Console (rare to hit) |
Common edge cases
Multiple replies before the first draft is approved. Each lands as a new Replies row. The older HubSpot drafts become stale — delete them manually before approving the newest, or you'll send the wrong response.
Lead replies from a different email. Currently logged as an orphan reply (no Leads-row match). Handle manually — match by domain, paste the right context into a manual response. (Future improvement: auto-match by domain.)
Lead replies after loop_status=reply_sent. Not auto-processed. The follow-up shows up in Instantly's Unibox for manual handling.
Pause the system. Stop running the skills. Sequences already loaded in Instantly continue sending — pause them manually in the Instantly UI.
A row has #ERROR! in a phone column. Old data from the pre-fix ingest. Re-run ingest on the archived CSV with the current code, or manually clear and re-fetch via Apollo enrichment.
Apollo CSV's Mobile Phone column is empty. Expected — Apollo gates mobile numbers behind a per-contact reveal that costs credits. Use Corporate Phone (auto-included) or reveal mobiles in the Apollo UI before exporting.
campaigns.json placeholder UUIDs still present. /spiral-weekly-enrichment will mark every lead as error_reason: instantly_push: 400 campaign_id invalid (or similar). Fix campaigns.json first, reset the errored rows, re-run.
Push reports uploaded=0, duped=N after a delete-and-retry. Instantly's workspace cache hasn't released the deleted emails yet, or the lead was removed only from the campaign (not the workspace). Confirm deletion at workspace level, then re-run with --force.
Push reports uploaded=0 but rows flipped to processing_status=pushed. Known bug: the push script unconditionally sets processing_status=pushed on every row in the chunk, even when no Instantly ID came back. After a no-op push, reset rows manually before retrying.
Case study setup. matched_case + case_link are the proof-line lookup. Today the portfolio is a constant of Aalo Atomics → https://www.spiralstudios.io/work/aalo. When you add a new case study, update the templates in spiral-weekly-enrichment/references/email_templates.md and the per-row matched_case/case_link columns. Don't paste the URL inside proof — case_link is its own Instantly column.
For specific error codes and recovery steps see error_handling.md.