Runbook

The precise operator playbook. If you only read one doc in spiral-shared/, read this one.

TL;DR

WhenWhatCommand
Monday morningPull new leads indrop CSV → /spiral-ingest-leads
Monday morningEnrich + push to Instantly/spiral-weekly-enrichment
Tue–Fri morningProcess new replies/spiral-daily-replies
Anytime client approvesSend 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.py only resyncs the header row; existing data rows stay put and will mis-align with the new column order. If you renamed/reordered columns in sheet_schema.py, also clear the data rows (rows 2+) on the Leads tab — easiest via ws.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):

CodeICPTierCampaign key
C1DreamT1dream_T1
C2NormalT2normal_T2
C3NormalT3normal_T3
C4LookalikeT2lookalike_T2
C5LookalikeT3lookalike_T3

Dream is T1-only. Normal and Lookalike skip T1. enrich_one.py clamps invalid combos (Dream → forces T1; Normal/Lookalike + T1 → demotes to T2) and logs the override in Audit_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:

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):

TierFunding stageTotal raisedMeaning
T1Series C+ / IPO≥ $50Mpremium, well-funded
T2Series A / Series B$5M – $50Mmid
T3Seed / Pre-seed / Grant< $5Mearly

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 N to ingest_csv.py. The /spiral-ingest-leads skill 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:

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:

  1. Select batch — next 100 pending rows, sorted: activity=active first, then icp_type priority (Dream > Normal > Lookalike), then row order.
  2. Apollo enrich per row — refreshes title/LinkedIn/seniority, pulls org-level signals (funding round, hiring, intent), seeds signals and vertical.
  3. Claude does internet research per lead, sets activity (active/passive) + composes signal_fact (copy-ready trigger prose).
  4. Claude writes the per-touch Instantly variables (opener, bridge, proof, asset_line, fu1_linefu3_line, breakup_line, cta, cta_soft, subject_t1subject_t5) following voice_guide.md + the lead's enriched context.
  5. 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:

  1. Delete the leads from Instantly (workspace, not just campaign — the delete must remove them from the whole workspace).
  2. 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",
    })
  3. 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:

  1. Fetches all new replies from Instantly since daily_reply_last_run_at (in Config tab).
  2. 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
  3. 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.
  4. Pushes all drafts as HubSpot Pending_Approval records 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:

  1. Pulls all HubSpot records with approval_status=approved.
  2. For each:
    • Writes the approved text into the Leads row (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)

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:

  1. Delete the HubSpot record manually
  2. Clear sent_to_hubspot and hubspot_record_id for that row in the Replies tab
  3. Re-run /spiral-daily-replies — the reply will be re-drafted

Best practices for high reply rates

  1. Always dry-run ingest first/spiral-ingest-leads runs --dry-run automatically and previews segment counts. Catch missing icp_type/tier tags before they hit the sheet.
  2. Keep batch sizes ≤100/week during warm-up. Instantly's sender reputation is more fragile than the volume cap suggests.
  3. Tag Apollo lists religiously. A clean {icp_type}_{tier} distribution means each lead routes to the right campaign with the right copy template.
  4. 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.
  5. 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 with Is it worth a quick 15 minutes?).
  6. Watch the audit log. Audit_Log tab logs every action. A spike in error rows is the earliest signal that something's wrong.
  7. Fill activity correctly. The select_batch step prioritizes activity=active. Garbage in here means low-intent leads jump the queue.

Resetting a lead

To force one lead back through the pipeline:

  1. In Leads, set processing_status back to pending (or empty).
  2. 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_t1subject_t5.
  3. 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

ResourceSoft thresholdWhere to check
Instantly emails/mo80% of plan capInstantly billing page
Instantly uploaded contacts80% of 1,000 (Growth)Instantly campaign view
Apollo credits20% remainingApollo billing
HubSpot daily API callscheck usage pageHubSpot settings
Google Sheets API60 req/min/userCloud 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 Atomicshttps://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 proofcase_link is its own Instantly column.

For specific error codes and recovery steps see error_handling.md.

  Have your own?

Paste your markdown, get a link like this.

Write your own
Runbook · DocHouse