Running a whole cold email operation from Claude Code: the setup, the subagents, and the hook that saved me from a 400-contact mistake
I posted a version of this for Codex. Enough people asked about Claude Code that it deserves its own writeup, because the good parts are different. Codex is better at "run this loop and do not stop". Claude Code is better at "hold my whole operation in its head and remember the rules I set three weeks ago".
The thing being automated here is not copywriting. It is the operational tax of running outbound: checking which campaign fell behind, spotting a dead sending account, fixing a broken import, sorting the inbox down to the replies that are actually replies. That work is boring, constant, and does not batch.
To hand it off you need a sending tool with a real command line or public API. Web dashboard only means browser automation, which breaks on every UI change. Check first, some platforms have an API they barely mention.
I use FirstSales, so these are its commands. Swap yours in, the workflow shape is the same.
Setup
npm install -g @firstsales.io/cli
export FIRSTSALES_API_KEY="your_key"
export FIRSTSALES_BASE_URL="https://api.app.firstsales.io"
firstsales whoami --json
firstsales doctor --pretty
whoami confirms which key, org and workspace you are pointed at without printing the key back. Worth caring about, since agent output ends up in transcripts.
Grab your ids, you will need them everywhere:
firstsales orgs list --json
firstsales workspaces list --org org_123 --json
If you run more than one account, use profiles instead of exporting env vars every session:
firstsales auth login --profile client-a --api-key "..." --base-url "https://api.app.firstsales.io"
firstsales auth status --profile client-a --json
Then any command takes --profile client-a. This turns out to matter a lot for agents, because "which client am I touching right now" is the single easiest thing for an agent to get wrong, and a profile name in the command is far harder to mix up than an org id that looks like every other org id.
CLAUDE.md is the actual product
Claude Code loads CLAUDE.md from your project directory every session. This file is where the whole setup lives. Spend an hour on it and everything downstream works. Skip it and you will re-explain your account structure daily.
# Outreach ops
CLI: firstsales. Always --json. Profile: client-a
Org: org_123 Workspace: ws_123
## Hard rules
- Read before write. whoami, then list/get, before any mutation.
- Never pass --confirm. If a task needs it, stop and ask me.
- --dry-run every write the first time you construct it. Show me the request.
- Retryable writes always get --idempotency-key, keyed off the email or row id.
- Exit codes: 0 ok, 1 runtime, 2 usage, 3 auth, 4 not found, 5 rate limited.
On 3 stop immediately, the key is wrong. On 5 stop and tell me, no retry loop.
- Campaign start/pause/resume is mine. You can report, not act.
## Command reference
firstsales campaigns list --org org_123 --workspace ws_123 --json
firstsales campaigns analytics --org ... --workspace ... --campaign camp_123 --json
firstsales campaigns progress --org ... --workspace ... --campaign camp_123 --json
firstsales campaigns events --org ... --workspace ... --campaign camp_123 --json
firstsales inbox threads --org ... --workspace ... --json
firstsales connectors test --org ... --workspace ... --connector conn_123 --json
firstsales tracking-domains verify --org ... --workspace ... --domain domain_123 --json
firstsales billing credits --org org_123 --json
Writing out the exact command shapes is the highest-value part of the file. An agent that has seen the real flags will reproduce them. An agent reasoning from a docs page will invent flags that do not exist and then argue with the error.
Slash commands for the things you do every day
Anything you run more than twice goes in .claude/commands/ as a markdown file. It becomes a slash command.
.claude/commands/morning.md:
Check every campaign in the workspace.
For each: run campaigns analytics and campaigns progress.
Also run connectors list, then connectors test on each connector.
Report only:
- campaigns that sent under 60% of expected volume yesterday
- campaigns with unread replies
- connectors failing a test
- credit balance if under 20% of last month's burn
Change nothing. Read-only.
Now /morning is my whole daily check. Two minutes.
The first week this ran it found a sending account that had silently disconnected after a token refresh. Volume was down about a fifth and the dashboard showed a campaign that looked broadly fine. I would have caught that on Thursday, if at all.
.claude/commands/import.md handles the other recurring job:
Read the CSV path I give you.
For each row: firstsales contacts create with --idempotency-key "csv-<email>".
On a non-zero exit, log the email and the error, then continue to the next row.
Never stop the whole run for one bad row.
Report all failures grouped by error at the end.
The idempotency key is what makes the import restartable. Key it off the email, not a counter, and a crashed run can be re-run from the top with no duplicates. "Keep going and report" matters just as much, because the default agent instinct on an error is to stop and ask, and then you are supervising a 400-row job.
Subagents for inbox triage
This is where Claude Code pulls ahead. You can define a subagent with its own instructions and a narrow tool list, so the thing reading your inbox physically cannot start a campaign.
.claude/agents/inbox-triage.md:
---
name: inbox-triage
description: Classify inbox replies and prepare responses for human approval.
tools: Bash, Read
---
Pull threads with: firstsales inbox threads --org ... --workspace ... --json
Read each with: firstsales inbox thread --org ... --workspace ... --thread <id> --json
Classify: positive, question, referral, not-now, auto-reply, negative.
For questions, ground the answer in our docs:
firstsales kb query --org ... --workspace ... --kb kb_123 --data '{"query":"<their question>"}'
Never approve or reject a draft. Never send. Output the queue and stop.
I run it, it hands me a sorted queue, I approve:
firstsales inbox approve-draft --org ... --workspace ... --email email_123 --idempotency-key approve-email-123
firstsales inbox reject-draft --org ... --workspace ... --email email_123 --json
Honest limitation: classification is not perfect. It has read a sarcastic "sure, send it over" as positive more than once. Harmless when a human is the last step. Not harmless on autopilot, which is why I have not built autopilot and do not plan to. The bottleneck was never typing replies, it was reading forty threads to find the six worth answering. Sorting alone gets you most of the way.
The hook
Claude Code has a PreToolUse hook that runs before a tool call and can block it. This is the piece I would not run without.
.claude/settings.json:
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "grep -q -- '--confirm' <<< \"$CLAUDE_TOOL_INPUT\" && { echo 'blocked: --confirm requires a human' >&2; exit 2; } || exit 0"
}]
}]
}
}
Exit 2 blocks the call and feeds the message back to Claude. So even if a prompt goes sideways and the model decides a delete is reasonable, the destructive flag never reaches the shell. Belt and braces, since the CLI already refuses destructive commands without --confirm, but the failure I am guarding against is the model correctly figuring out it needs --confirm and then adding it.
Second layer, and do this regardless of hooks: give the agent its own scoped key.
firstsales api-keys create \
--org org_123 \
--data '{"name":"claude code ops","scopes":["campaigns:read","contacts:write"],"accessLevel":"workspace"}' \
--json
Read on campaigns, write on contacts, nothing else. Worst case from a bad prompt is some junk contacts instead of a deleted campaign. Raw keys are shown once at creation and never retrievable, so store it properly the first time. Revoke when the project ends:
firstsales api-keys revoke --org org_123 --key key_123 --confirm --json
Weekly reporting, which I did not expect to like
/report last 7 days
.claude/commands/report.md pulls analytics and events per campaign, then writes markdown to reports/. Because Claude Code can write files, the output is a document I can send a client instead of a wall of terminal text. Cost me twenty minutes to set up and killed a job I used to hate.
If a command does not exist yet, there is an escape hatch straight to the API:
firstsales api GET /api/v1/organizations/org_123/workspaces --json
Useful when the docs list an endpoint the CLI has not wrapped.
What is not there
Signals and webhooks return unsupported_operation. No public event surface yet, so nothing is event-driven. My scheduling is a cron that runs a prompt, which is cruder than I want.
--auto-approve on the copilot command is best-effort only. The server decides, not the CLI, and a still-pending turn exits 1 instead of polling forever. Reasonable design, but do not build a pipeline that assumes it went through.
Exit codes were redefined in 0.1.1. Old scripts that branch on them are wrong now.
Where to start
Do not start with imports or approvals. Start read-only: get /morning working, live with it a week, and see whether the agent reads your account correctly before you let it write anything. Every bad story I have heard about agents and production tools starts with someone skipping that week.
My own numbers, roughly. Campaign checks, import repair, inbox sorting and connector debugging used to run six to eight hours a week. Now closer to two, and most of that two is me approving replies, which is the part that should be mine.
The real gain is not the hours. It is that the daily check happens daily now, because it costs one slash command instead of forty minutes of clicking through tabs. Broken sending accounts get caught the morning they break.
If you are running something like this on another platform, post your equivalent commands. Half the value of these threads is finding out which tools expose enough surface to be worth automating in the first place.
Posted Aug 5, 2026, 1:34 PM UTC