Open-source CLI cold email sequence engine. Single binary, SQLite by default, Postgres via COLD_CLI_DATABASE_URL, no SaaS required.
Supports Google Workspace/Gmail through gws, plus generic SMTP/IMAP accounts for other email hosts. Works great with coding agents (Claude Code, Cursor, etc.) or directly from the terminal.
go install github.com/andersmyrmel/cold-cli/cmd/cold-cli@latestgws is required only when using Google Workspace/Gmail accounts. Generic SMTP/IMAP accounts do not require gws.
cold-cli supports two storage modes:
- SQLite (default) - local file at
~/.cold-cli/data.db - Postgres - activated by setting
COLD_CLI_DATABASE_URL
Examples:
# Local default mode
cold-cli init
# Shared/server mode
export COLD_CLI_DATABASE_URL='postgresql://user:pass@host:5432/cold_cli?sslmode=require'
cold-cli initImportant for Postgres mode:
- use a direct Postgres connection for
tick - do not use a transaction-pooled/pgbouncer pooler URL for the worker path
tickuses advisory locks in Postgres mode, which require stable session semantics
# Initialize
cold-cli init
# Check domain deliverability
cold-cli doctor
# Add a Google Workspace/Gmail sending account (opens browser for OAuth)
cold-cli account add you@company.com
# Or add a generic SMTP/IMAP account
export MAIL_PASSWORD='app-password-or-mailbox-password'
cold-cli account add-smtp you@company.com \
--smtp-host smtp.example.com \
--smtp-password-ref env:MAIL_PASSWORD \
--imap-host imap.example.com
cold-cli account verify you@company.com
# Scaffold example sequence + leads files (optional)
cold-cli campaign init
# Create a campaign
cold-cli campaign create \
--name "q1-outreach" \
--sequence sequence.yml \
--leads leads.csv \
--accounts you@company.com
# Review the full schedule before sending anything
cold-cli campaign preview q1-outreach
# Activate when ready
cold-cli campaign activate q1-outreach
# Send due emails (run manually or via cron)
cold-cli tick
# Check results
cold-cli stats q1-outreach
cold-cli stats q1-outreach --variants # A/B test results
cold-cli log # recent activitySequences are YAML files with steps, delays, and optional A/B variants:
name: Q1 Agency Outreach
defaults:
from_name: "Alex"
steps:
- step: 1
delay: 0
subject: "{{first_name}}, quick question about {{company}}"
body: |
Hi {{first_name}},
Saw that {{company}} is growing fast...
variants:
- subject: "{{company}} + lifecycle emails"
body: |
Hi {{first_name}}, wanted to reach out...
- step: 2
delay: 3
body: |
Hey {{first_name}}, circling back...
- step: 3
delay: 5
body: |
Last note - just wanted to make sure this didn't get buried.delayis in days after the previous step- Steps without a
subjectsend as replies in the same thread {{placeholders}}are replaced from CSV columnsvariantsenable A/B testing (assigned per lead at creation)
email,first_name,company,schedule_timezone
john@acme.com,John,Acme Inc,America/New_York
jane@bigcorp.com,Jane,BigCorp,Europe/Osloemail is the only required column. All other columns are driven by what {{placeholders}} your sequence uses. Extra columns beyond the built-in fields (first_name, last_name, company) are stored as custom fields and available for templates at send time.
Supported scheduling override columns:
schedule_timezone- optional IANA timezone per lead, for exampleAmerica/New_YorkorEurope/Oslo
Scheduling behavior:
-
Campaign
timezoneis still the default for leads withoutschedule_timezone -
Campaign send window and send days remain campaign-level settings
-
If
schedule_timezoneis present, that lead uses the campaign window interpreted in that lead's local timezone -
If leads need materially different local windows, split campaigns by geography for now
-
Validation at creation - mismatched variables produce actionable errors with "Did you mean?" suggestions
-
Aliases - common names like
{{name}}→first_nameare resolved automatically -
Reserved names blocked - CSV columns named
subject,body,step,delay, orvariantare rejected (they conflict with sequence YAML fields) -
Schedule override validation - invalid
schedule_timezonevalues fail campaign creation / add-leads with a clear error -
Reimport updates - if a lead already exists, its fields are updated from the new CSV (not silently skipped)
-
Safety at send time - unresolved variables are stripped (never sent literally); emails with empty subject or body are not sent
cold-cli init # set up ~/.cold-cli/, config, and the active DB backend
cold-cli doctor [domain...] # check MX, SPF, DKIM, DMARC, domain age
cold-cli --workspace workspace-a account add <email> # add Google Workspace/Gmail account with gws OAuth
cold-cli --workspace workspace-a account add <email> --no-login # add Google account without OAuth (already authed)
cold-cli --workspace workspace-a account add-smtp <email> # add generic SMTP/IMAP account
cold-cli --workspace workspace-a account add-smtp <email> --smtp-host smtp.example.com --smtp-password-ref env:MAIL_PASSWORD --imap-host imap.example.com
cold-cli account update-smtp <email> # update SMTP/IMAP host, port, user, secret refs, TLS, or daily limit
cold-cli account verify <email> # verify SMTP/IMAP connectivity and auth
cold-cli --workspace workspace-a account list # list accounts in one workspace
cold-cli account list --all-workspaces # audit all accounts across workspaces
cold-cli account update <email> # update settings (--daily-limit)
cold-cli account pause <email> # deactivate, cancel pending sends
cold-cli account resume <email> # reactivate a paused account
cold-cli account remove <email> # deactivate (re-add later with account add)
cold-cli campaign init [directory] # scaffold example sequence.yml + leads.csv
cold-cli campaign validate-leads --leads <csv> # MX + SMTP recipient preflight before create/add-leads
cold-cli --workspace workspace-a campaign create --name --sequence --leads --accounts [--start-date YYYY-MM-DD] [--send-days "1,2,3,4,5"]
cold-cli --workspace workspace-a campaign create --name --sequence-inline '...' --leads-inline '...' --accounts # no files needed
cold-cli campaign clone <source> --name <new> --leads <csv>
cold-cli campaign add-leads <name|id> --leads <csv> # or --leads-inline '...'
cold-cli campaign remove-lead <name|id> <email> # remove one lead from a campaign
cold-cli campaign preview <name|id> # see full schedule before activating
cold-cli campaign preview <name|id> --render # see rendered emails for first lead, with stripped-var warnings
cold-cli campaign preview <name|id> --render --lead <email> # render for specific lead
cold-cli campaign activate <name|id> # start sending
cold-cli campaign activate <name|id> --send-now # activate and send immediately
cold-cli campaign send-now <name|id> # set all pending sends to now
cold-cli campaign pause <name|id> # stop sending
cold-cli campaign resume <name|id> # resume
cold-cli campaign status <name|id> # details + reply rate + next/last send
cold-cli --workspace workspace-a campaign list # list workspace campaigns (with send window + days)
cold-cli campaign update <name|id> # update sequence, send window/days, timezone, gaps
cold-cli campaign update <name|id> --send-days "0,1,2,3,4,5,6" # reschedule pending sends only
cold-cli campaign delete <name|id> # delete campaign and all data
cold-cli campaign retry <name|id> # reset failed sends back to pending
cold-cli campaign retry <name|id> --step N # retry only failed sends for step N
cold-cli tick # process replies, bounces, send due emails
cold-cli tick --dry-run # show what would happen
cold-cli tick --now # ignore schedule, send all pending immediately
cold-cli inbox backfill --dry-run # preview historical inbox thread snapshot backfill
cold-cli inbox backfill # store missing reply + related sent snapshots
cold-cli stats [campaign] # sent/replied/bounced per campaign
cold-cli stats <name> --leads # per-lead breakdown
cold-cli stats <name> --variants # A/B test results with reply rates
cold-cli log [campaign] # recent activity (sends, replies, bounces)
cold-cli log --limit 50 # show more events
cold-cli lead list # list all leads
cold-cli lead list --domain <domain> # filter by domain
cold-cli lead list --status <status> # filter by status
cold-cli lead pause <email> # pause across all campaigns
cold-cli lead resume <email> # undo pause, restore pending sends
cold-cli lead blacklist <email|domain> # blacklist + cancel pending sends
All commands support --json for programmatic use.
cold-cli is the source of truth for account and campaign ownership. Accounts
and campaigns carry a workspace_id; commands use --workspace <id>, then
COLD_CLI_WORKSPACE_ID, then default.
Use explicit workspaces for hosted or multi-brand setups:
cold-cli --workspace workspace-a account add-smtp sender@workspace-a.example \
--smtp-host smtp.example.com \
--smtp-password-ref env:WORKSPACE_A_MAIL_PASSWORD \
--imap-host imap.example.com
cold-cli --workspace workspace-a campaign create \
--name workspace-a-june \
--sequence sequence.yml \
--leads leads.csv \
--accounts sender@workspace-a.exampleCampaign creation only accepts active accounts from the same workspace. Do not
infer ownership from sender domains except as a one-time suggestion before
writing the explicit workspace_id.
tick can post new reply and unsubscribe alerts to a Discord channel through an
incoming webhook. This is intended for SMTP/IMAP inboxes where there is no Gmail
phone notification surface.
export DISCORD_WEBHOOK_URL='https://discord.com/api/webhooks/...'
export DISCORD_WEBHOOK_USERNAME='cold-cli Replies'
export DISCORD_WEBHOOK_AVATAR_URL='https://example.com/brand/logo.png'
cold-cli tickNotes:
- The webhook URL is a secret. Store it in your local/production env file, not in git.
- Set
COLD_CLI_DISCORD_NOTIFY=0to temporarily disable notifications while keeping the webhook configured. - By default, Discord only alerts on SMTP/IMAP account replies because Gmail/GWS accounts already have Gmail notifications. Set
COLD_CLI_DISCORD_PROVIDERS=allto notify for every provider, or a comma-separated list such assmtp_imap,gws. - Set
DISCORD_WEBHOOK_USERNAMEandDISCORD_WEBHOOK_AVATAR_URLto override the Discord webhook's default display name and icon. - The first enabled run initializes the notification cursor before polling inboxes, so old historical replies are not dumped into Discord. Replies discovered during that same tick still notify.
- Alerts include campaign, inbox, lead, sender, subject, and a short snippet. Full message bodies are not sent.
- Discord mentions are disabled in webhook payloads so prospect text cannot trigger
@everyoneor role pings.
All send times are pre-computed when you create a campaign. Each send becomes a stored row with a specific send_at timestamp, assigned account, and variant. This means:
campaign previewshows the sender-capacity-aware schedule before you activate- schedules are rebalanced across
activeanddraftcampaigns that share an account tickuses the same rebalance logic as preview before loading due rows- Agents can review and approve the full timeline
- Optional lead-level
schedule_timezoneoverrides use the campaign send window in each lead's local timezone campaign update --send-days/--send-window-*/--timezonerecalculates existingpendingsends without touchingsent,failed,skipped, orcancelledrows- For leads with no sent history, update recomputes the first pending send from
max(now, campaign start date)under the new window/day/timezone rules, then chains later pending sends from that new anchor - For leads already in flight, update preserves sent history and only reschedules future pending sends
- If a prior step is actually sent later than planned, future pending follow-ups are re-anchored from the actual
sent_atso configured delays still hold
Current limitation:
- Only timezone is lead-specific today. Send window start/end and send days are still campaign-level.
tick is a single idempotent command that does everything per invocation:
- Poll inboxes for replies via the account provider → match via In-Reply-To headers → mark lead replied
- Poll inboxes for bounces via the account provider → detect via thread/message matching → mark bounced
- Detect unsubscribe requests → auto-blacklist lead globally
- Rebalance pending sends for the affected sender accounts using real daily-limit capacity
- Find sends where
send_at <= nowand campaign is active - Re-check each pending row just before send so stale preloaded rows cannot fire
- Send each email through its account provider (
gwsor SMTP) with 90-140 second random gaps - After each successful send, rebalance that sender again so future follow-ups chain from actual send time
- Respect daily limits, send windows, and send days
Run it manually, via cron (*/10 * * * *), or have an agent call it. All tick activity is logged to ~/.cold-cli/tick.log as structured JSON.
Run lead validation before campaign create, campaign clone, or campaign add-leads:
cold-cli campaign validate-leads --leads leads.csvThe validator expects the same email column used by campaign CSVs. It performs syntax validation, MX lookup, catch-all detection, and SMTP RCPT TO checks for company-domain recipients. By default it exits non-zero if any row fails or needs manual review, so it can be used as a hard preflight gate.
Default policy:
verifiedcompany-domain recipients pass.rejectedrecipients and no-MX domains fail.- Gmail/free-mail domains require manual review because exact mailboxes are not reliably SMTP-verifiable.
- Catch-all domains require manual review because the exact mailbox is not verified.
- Unknown SMTP results require manual review.
Use overrides only when you have a stronger manual reason:
cold-cli campaign validate-leads --leads leads.csv --allow-free-email
cold-cli campaign validate-leads --leads leads.csv --allow-catch-all
cold-cli campaign validate-leads --leads leads.csv --allow-unknownDo not run recipient validation inside tick: live SMTP checks are slow and can be inconclusive. Validate during campaign prep, then import only rows that pass or have an explicit manual approval.
Matches inbox messages to sent emails using In-Reply-To headers, with provider thread/message IDs as a fallback where available. When a reply is detected, the lead is marked replied and remaining sends for that lead are cancelled. With stop_on_domain_reply, all other leads on the same domain are paused.
Unsubscribe requests ("unsubscribe", "remove me", "opt out", etc.) are auto-detected and blacklist the lead globally across all campaigns.
Three-strategy fallback:
- Thread/message matching - NDR can be tied back to a sent email
- X-Failed-Recipients header - standard MTA header
- Snippet parsing - extract bounced email from NDR text
Google Workspace/Gmail accounts use gws OAuth and Gmail API send/inbox operations:
cold-cli --workspace workspace-a account add sender@company.comGeneric SMTP/IMAP accounts store server settings and secret references. SMTP sends mail; IMAP polls for replies, unsubscribes, and bounces. Raw passwords are not stored. Use env:NAME references and provide the environment variable wherever cold-cli tick runs:
export MAIL_PASSWORD='app-password-or-mailbox-password'
cold-cli account add-smtp sender@company.com \
--smtp-host smtp.example.com \
--smtp-password-ref env:MAIL_PASSWORD \
--imap-host imap.example.com
cold-cli account verify sender@company.comFor cron, systemd, or VPS workers, prefer an explicit env file instead of shell-wide exports:
cat > ~/.cold-cli/secrets.env <<'EOF'
MAIL_PASSWORD=app-password-or-mailbox-password
EOF
chmod 600 ~/.cold-cli/secrets.env
cold-cli --env-file ~/.cold-cli/secrets.env account verify sender@company.com
cold-cli --env-file ~/.cold-cli/secrets.env tickcold-cli never auto-loads repo .env files. --env-file is explicit and
applies before the command resolves env:NAME secret references.
Hosted callers can also store opaque secret:ID references and provide a
custom SecretResolver when running the engine. The default CLI resolver only
resolves env:NAME.
If provider settings change, update only the fields that changed and verify again:
cold-cli account update-smtp sender@company.com \
--smtp-host mail.example.com \
--smtp-password-ref env:MAIL_PASSWORD
cold-cli account verify sender@company.comDefaults:
--smtp-userdefaults to the account email--imap-userdefaults to the SMTP username--imap-password-refdefaults to the SMTP password reference--smtp-tls ssldefaults to port465;starttlsdefaults to587;nonedefaults to25--imap-tls ssldefaults to port993;starttlsandnonedefault to143
Campaigns can use one account or rotate across multiple accounts, regardless of provider:
cold-cli account add sender1@company.com
cold-cli account add-smtp sender2@company.com --smtp-host smtp.example.com --smtp-password-ref env:MAIL_PASSWORD --imap-host imap.example.com
# Single account
cold-cli campaign create --accounts sender1@company.com ...
# Round-robin across accounts
cold-cli campaign create --accounts sender1@company.com,sender2@company.com ...When round-robin is used, all steps for a given lead use the same account so follow-ups keep provider-specific thread/message continuity.
Clone a campaign with new leads. Copies sequence, settings, and accounts:
cold-cli campaign clone q1-outreach --name q2-outreach --leads new-leads.csvAdd more leads to a running campaign:
cold-cli campaign validate-leads --leads more-leads.csv
cold-cli campaign add-leads q1-outreach --leads more-leads.csvAutomatically skips leads already in the campaign, blacklisted, or bounced.
For mixed geographies, you can either:
- use one campaign with per-lead
schedule_timezonewhen the same local window is acceptable for everyone - split campaigns by geography when regions need different local windows or send days
Check your sending domains for deliverability issues:
cold-cli doctor # auto-checks all account domains
cold-cli doctor example.com # check specific domainChecks MX records, SPF, DKIM (19 common selectors), DMARC, and domain age via WHOIS.
~/.cold-cli/config.yml:
default_timezone: America/New_York
default_daily_limit: 50
min_gap_seconds: 90
max_gap_seconds: 140
send_window_start: "09:00"
send_window_end: "17:00"
send_days: "1,2,3,4,5"
# Unsubscribe reply detection is always on.
# List-Unsubscribe header is off by default (not needed for cold email from personal Gmail).
unsubscribe_header: false
unsubscribe_subject: Unsubscribesend_days in config is the default for new campaigns. Override it per campaign with cold-cli campaign create --send-days ....
- Go - single binary, no runtime deps
- SQLite or Postgres - SQLite at
~/.cold-cli/data.dbby default, Postgres viaCOLD_CLI_DATABASE_URL - gws CLI - subprocess calls for Gmail API accounts (send, list, get)
- SMTP/IMAP - native transports for generic email hosts
- Cobra - CLI framework
- log/slog - structured JSON logging to
~/.cold-cli/tick.log
Backend notes:
- SQLite mode uses a local file lock at
~/.cold-cli/tick.lock - Postgres mode uses an advisory lock for
tick cold-cli initbootstraps whichever backend is active- Postgres worker deployments should use a direct connection string, not a pooler URL
See ARCHITECTURE.md for data model, tick flow diagrams, and design decisions.
MIT