Skip to content
NewSee how
cli

Weekly Campaign Report with Cron and the CLI Guide

Build a weekly campaign report with cron and the FirstSales CLI: dated snapshots, a field-agnostic diff, safe failure handling.

TL;DR: A weekly campaign report should come from a cron job that calls the FirstSales CLI, saves dated JSON snapshots, and diffs this week against last week with plain shell tools. A model can narrate the diff if you want prose, but it should never produce or touch the numbers.


#Why the numbers in your report must come from a script

Every outbound team has the same Monday ritual. Someone opens the app, screenshots a few charts, pastes them into a document, and adds a sentence about how the week went.

It takes an hour. It also produces a report nobody can reproduce, because the person who built it chose which charts to screenshot.

That is a weak foundation for decisions about spend and sending volume. A report you cannot regenerate is a report you cannot audit.

The fix is old and unglamorous. Run a scheduled job that asks the same questions of the same system at the same time every week, and save the answers.

Unix has done this since the 1970s, with cron. The crontab manual page describes the five time fields and the way a job runs with a minimal environment, which is the first thing that surprises people.

The FirstSales CLI fits this job well. Its README calls it a thin, JSON-first CLI over the Developer API, and it prints compact JSON by default and returns stable exit codes.

Those two properties are exactly what a scheduled job needs. JSON is easy to store and compare, and exit codes tell a script whether to continue or raise an alarm.

If you want the daily version of the same habit, see the daily outbound standup prompt. This post covers the weekly record that sits behind it.

#The point of view: no agent in the number path

Here is the position this post takes. In a weekly campaign report, no language model should produce, round, sum or retype a number.

Models are excellent at turning a diff into a readable paragraph. They are unreliable at arithmetic over long JSON, and they never tell you when they quietly dropped a row.

So the design splits the work in two. A script collects and compares the data, and an optional model step turns the finished comparison into prose.

If the model step fails or hallucinates, the numbers are still right. You can delete the paragraph and keep the report.

This split also solves a trust problem. Your leadership team can read the numbers knowing a script produced them, and read the prose knowing it came after.

The same principle appears in the wider outbound stack. The five layers of an AI outbound agent stack post puts deterministic collection below reasoning for the same reason.

There is a second argument, which is cost. A cron job that only calls the CLI uses no model tokens for the numbers, and runs in a few seconds.

#What a weekly campaign report should answer

A weekly report has a different job from a daily standup. The standup asks what to do today, and the weekly report asks whether the campaign is working.

Keep the questions few and fixed.

  1. What happened this week? Sends, replies, bounces and any events worth noting for each live campaign.
  2. How does it compare with last week? The same numbers, side by side, with the change.
  3. Is the sender side healthy? Connector status, warmup status and open alerts.
  4. What did it cost? Credit balance and usage for the period.
  5. What is the pipeline effect? Replies that turned into deals, if you track deals in FirstSales.

Reply rate and bounce rate are the numbers to watch. The post on cold email reply rate benchmarks for 2026 helps you judge whether your numbers are normal for your segment.

For a cost lens, cost per meeting in outbound shows how to turn a week of activity into a unit cost. Your report supplies the raw counts for that calculation.

Skip open rate. The case against it is in email open rates as dead metrics, and a weekly report is no place for a number that moves for reasons unrelated to your copy.

#Complaint and bounce watch

Google's email sender guidelines tell bulk senders to keep the spam rate reported in Postmaster Tools below 0.10% and to avoid reaching 0.30%. Your weekly report cannot read Postmaster Tools, but it can show the bounce movement that usually moves first.

For bounce thresholds and what to do about them, see cold email bounce rate. A weekly line that says bounces rose two weeks in a row is a prompt to open that article.

#Prepare the machine, the key and the folders

Pick one machine to run the job. A small always-on server or a spare workstation is fine, as long as it is on at the scheduled time.

Install Node 20 or later, since the CLI README lists that as its requirement, then install the CLI.

bash
npm install -g @firstsales.io/cli firstsales --help jq --version

You also need jq, a small command-line JSON processor. The jq manual covers everything used below.

#Create a reporter key

Create a key just for this job, with read scopes only. Name it after the job so a future audit makes sense.

bash
firstsales api-keys create \ --org org_123 \ --data '{"name":"weekly-report-reader","scopes":["campaigns:read"],"accessLevel":"workspace"}' \ --json

The raw key appears once. Copy it into a protected file in the next step, and do not paste it anywhere else.

Never pass --idempotency-key to this command. The README excludes api-keys create from idempotency because raw keys are reveal-once.

The scope list above is the README's example trimmed to one scope. A full report also reads connectors, alerts, billing and possibly deals, so look up the exact scope names for those in the Developer API authentication docs and add only what you need.

If you want a deeper treatment of key design, read API keys for outbound agents. One fact from the README matters for scheduled jobs in particular: keys fail closed if the creator loses organization access or is suspended.

That means a report key created by someone who later leaves will silently stop working. Create it from an account that will stay, and put a rotation reminder in the calendar.

#Store the key where cron can read it and others cannot

Cron runs with a minimal environment, so your shell profile is not loaded. Put the variables in a dedicated file.

bash
mkdir -p ~/outbound-reports/{bin,snapshots,reports,logs} install -m 600 /dev/null ~/.config/firstsales-report.env cat > ~/.config/firstsales-report.env <<'EOF' FIRSTSALES_API_KEY=<YOUR_FIRSTSALES_API_KEY> FIRSTSALES_BASE_URL=https://api.app.firstsales.io ORG_ID=org_123 WORKSPACE_ID=ws_123 EOF

Replace the placeholder with the real key by editing the file in a text editor, not by typing the key into a shell command that lands in your history. The install -m 600 line created the file readable by you alone.

Keep that file out of version control. If you keep your scripts in git, add the env file's path to your ignore rules before the first commit.

#List the campaigns to report on

Keep a plain text file with one campaign id per line. You maintain it by hand, so the report only covers campaigns you chose.

bash
cat > ~/outbound-reports/campaign-ids.txt <<'EOF' camp_123 camp_456 EOF

You can get the ids from firstsales campaigns list. A hand-kept list also prevents a new test campaign from appearing in the weekly report by accident.

#Look at the real JSON before you write any parsing

Before you automate anything, run each read once and look at the output. This post deliberately avoids naming JSON fields, because the live response is the authority and shapes can change between CLI releases.

bash
set -a; . ~/.config/firstsales-report.env; set +a firstsales whoami --json firstsales campaigns analytics --org "$ORG_ID" --workspace "$WORKSPACE_ID" \ --campaign camp_123 --pretty | head -60 firstsales campaigns progress --org "$ORG_ID" --workspace "$WORKSPACE_ID" \ --campaign camp_123 --pretty | head -60

Read the output once with care. Note which values are counts, which are rates and which are timestamps.

Timestamps are the dangerous kind. A value that changes every run, like a "last updated" time, will show up as a difference in every weekly diff unless you exclude it. You will handle that in the diff script with an ignore list.

#Why whoami comes first

The first command in the block is whoami. The README describes it as the FirstSales equivalent of npm whoami, validating the active token and printing the user, key, organization and workspace context.

Your script should call it first and stop if the output does not match the ids in your env file. A weekly report from the wrong workspace is worse than none.

#The collection script

The flow in one picture:

graph TD
  A[cron Monday 07:00] --> B[collect.sh with lock]
  B --> C{whoami matches env}
  C -->|no| X[alert and stop]
  C -->|yes| D[dated JSON snapshots]
  D --> E[diff.sh vs last week]
  E --> F[report text file]
  F --> G[optional model narration]
  F --> H[human reads and decides]

The collection script does one thing. It calls the read commands, saves each result to a dated file, and records any failure.

bash
#!/usr/bin/env bash # ~/outbound-reports/bin/collect.sh set -uo pipefail set -a; . "$HOME/.config/firstsales-report.env"; set +a ROOT="$HOME/outbound-reports" STAMP="$(date +%F)" OUT="$ROOT/snapshots/$STAMP" LOG="$ROOT/logs/collect-$STAMP.log" mkdir -p "$OUT" # One run at a time. mkdir is atomic, so it works on Linux and macOS. LOCK="$ROOT/.collect.lock" if ! mkdir "$LOCK" 2>/dev/null; then echo "another run holds the lock" >> "$LOG"; exit 0 fi trap 'rmdir "$LOCK"' EXIT fs() { firstsales "$@" --org "$ORG_ID" --workspace "$WORKSPACE_ID" --json; } FAILED=0 # the last non-zero exit code, so the runner can alert on it run() { # run <outfile> <command words...> local file="$1"; shift if fs "$@" > "$OUT/$file.json" 2>> "$LOG"; then return 0 else local rc=$? FAILED=$rc echo "FAILED rc=$rc: $* " >> "$LOG" rm -f "$OUT/$file.json" return "$rc" fi } # Identity gate firstsales whoami --json > "$OUT/whoami.json" 2>> "$LOG" || { echo "whoami failed" >> "$LOG"; exit 3; } # Sender side run connectors connectors list run alerts alerts list # Per campaign while read -r CAMP; do [ -z "$CAMP" ] && continue run "progress-$CAMP" campaigns progress --campaign "$CAMP" run "analytics-$CAMP" campaigns analytics --campaign "$CAMP" run "events-$CAMP" campaigns events --campaign "$CAMP" done < "$ROOT/campaign-ids.txt" # Billing is organization level, so it takes no workspace flag. firstsales billing credits --org "$ORG_ID" --json > "$OUT/billing-credits.json" 2>> "$LOG" \ || { FAILED=$?; echo "FAILED billing credits" >> "$LOG"; } ls "$OUT" > "$OUT/MANIFEST.txt" exit "$FAILED"

Let me walk through why each part exists. Skipping the reasons is how scripts rot.

#Why set -uo pipefail and not set -e

The script uses -u and pipefail, but not -e. A scheduled report should keep going when one campaign read fails, so it still saves the other campaigns.

The run function records the failure and moves on. The manifest file at the end tells you exactly which files exist, so a gap is visible.

#Why a lock

If a run takes longer than expected, or you start one by hand while cron starts another, two copies would write the same files. The mkdir lock prevents that.

mkdir either creates the directory or fails, atomically, which makes it a portable lock. The trap removes it when the script exits.

#Why every output goes to a file

Each read goes to its own file. That keeps files small, makes a failed read easy to isolate, and lets the diff script compare file to file.

The events file can get long, because it is a stream. Treat it as an archive and diff only the analytics and progress files.

#Note on billing flags

Billing commands in the README take --org only, which is why the script calls billing credits directly instead of through the fs helper that adds --workspace. If you pass an extra flag a command does not publish, check the CLI's usage output rather than guessing.

#The safe part: everything here is a read

Every command in the script reads state. There is no campaigns start, no pause, no contacts delete, and no email send in the file.

Keep it that way. The reporter key has read scopes only, so even a typo in the script cannot write.

#The diff script: week over week with no field names

Now the interesting part. You want to compare this week's analytics to last week's without knowing the JSON structure in advance.

The trick is to flatten each JSON file into lines of path<TAB>value, then compare the lines. This works for any shape.

bash
#!/usr/bin/env bash # ~/outbound-reports/bin/diff.sh <old-dir> <new-dir> set -uo pipefail OLD="$1"; NEW="$2" # Optional: paths to ignore, one regex per line (timestamps, ids that change). IGNORE="$HOME/outbound-reports/ignore-paths.txt" [ -f "$IGNORE" ] || : > "$IGNORE" flatten() { jq -r 'paths(type != "array" and type != "object") as $p | "\($p | map(tostring) | join("."))\t\(getpath($p))"' "$1" | sort } for NEWFILE in "$NEW"/analytics-*.json "$NEW"/progress-*.json; do [ -f "$NEWFILE" ] || continue BASE="$(basename "$NEWFILE")" OLDFILE="$OLD/$BASE" echo "=== $BASE" if [ ! -f "$OLDFILE" ]; then echo "(no previous file)"; continue; fi join -t $'\t' -a1 -a2 -e MISSING -o 0,1.2,2.2 \ <(flatten "$OLDFILE") <(flatten "$NEWFILE") \ | grep -v -E -f "$IGNORE" \ | awk -F'\t' ' $2 != $3 { delta = "" if ($2 ~ /^-?[0-9.]+$/ && $3 ~ /^-?[0-9.]+$/) delta = sprintf(" (%+g)", $3 - $2) printf "%-48s %s -> %s%s\n", $1, $2, $3, delta }' done

Run it with two snapshot folders and you get one line per changed value, with a numeric delta where both sides are numbers.

bash
~/outbound-reports/bin/diff.sh \ ~/outbound-reports/snapshots/2026-10-05 \ ~/outbound-reports/snapshots/2026-10-12 \ > ~/outbound-reports/reports/2026-10-12.txt

Those dates are examples. Use the folders your own runs created.

#Why this approach holds up

The script never mentions a metric name. If the API adds a field next release, it appears in the diff automatically as MISSING -> value.

If a field disappears, it appears as value -> MISSING. That alone is a useful early warning that something changed upstream.

The join command pairs lines by path, and -a1 -a2 -e MISSING keeps lines that exist on only one side. The awk step prints only the lines that differ.

#Handling noise with an ignore list

The first run will show noise, such as timestamps that change every day. Add a regex per line to ignore-paths.txt for each noisy path.

bash
cat >> ~/outbound-reports/ignore-paths.txt <<'EOF' (^|\.)updatedAt (^|\.)generatedAt EOF

Those two lines are examples of how to write the ignore file, not a claim about the API's field names. Use the exact path text you saw in the real diff output.

This ignore file is the one piece of tuning the job needs. After two or three weeks it stops changing.

#Turning the diff into a readable table

For a team that wants a table, extract a few chosen paths from the flattened output. Choose the paths after you have seen the real data.

bash
flatten_for() { jq -r 'paths(type != "array" and type != "object") as $p | "\($p | map(tostring) | join("."))\t\(getpath($p))"' "$1"; } # Replace PATH_I_SAW with an exact path from your own flattened output. flatten_for ~/outbound-reports/snapshots/2026-10-12/analytics-camp_123.json \ | awk -F'\t' '$1 == "PATH_I_SAW" { print $2 }'

PATH_I_SAW is a placeholder, and the command does nothing useful until you replace it. That honesty is deliberate: a report built on guessed field names would be fiction.

#The cron entry, time zones and overlapping runs

With the scripts in place, wrap them in one runner and schedule it.

bash
#!/usr/bin/env bash # ~/outbound-reports/bin/weekly.sh set -uo pipefail ROOT="$HOME/outbound-reports" "$ROOT/bin/collect.sh"; RC=$? TODAY="$(date +%F)" # Find the most recent earlier snapshot folder. PREV="$(ls -1 "$ROOT/snapshots" | grep -v "^$TODAY$" | sort | tail -1)" if [ -n "$PREV" ]; then "$ROOT/bin/diff.sh" "$ROOT/snapshots/$PREV" "$ROOT/snapshots/$TODAY" \ > "$ROOT/reports/$TODAY.txt" fi exit $RC

Now schedule it. Open your crontab with crontab -e and add one line.

0 7 * * 1 $HOME/outbound-reports/bin/weekly.sh >> $HOME/outbound-reports/logs/cron.log 2>&1

The five fields mean minute 0, hour 7, any day of month, any month, and day of week 1, which is Monday. The redirect at the end sends both normal output and errors to a log file, so you can see what happened.

#Time zones

Cron uses the system time zone of the machine it runs on. If your server runs on UTC and your team is in Asia/Calcutta, a job set for 07:00 runs at 12:30 local time.

Some cron implementations support a time zone variable and some do not, so check the manual for yours. A safer approach is to set your server's zone deliberately, or to convert the time yourself and write a comment above the cron line.

# 07:00 Monday in Asia/Calcutta is 01:30 Monday UTC 30 1 * * 1 $HOME/outbound-reports/bin/weekly.sh >> $HOME/outbound-reports/logs/cron.log 2>&1

Pick a time before your team's Monday planning meeting, so the report is ready when people sit down.

#Minimal environment

Cron does not load your shell profile. That is why the scripts source the env file themselves and use full paths.

If firstsales is not found under cron, the cause is almost always the PATH. Add a PATH= line at the top of the crontab, or call the binary by its full path.

#macOS

On a Mac, cron still works, but it may need permissions granted to run. If you prefer the native scheduler, launchd does the same job with a property list file; the scripts above stay the same either way.

#Overlap

The lock in the collection script prevents two runs at once. If a run is still going when the next one starts, the second exits quietly and logs why.

A weekly job should never overlap, so if you see that log line, something is badly slow. Check the CLI's connection and your rate limits.

#Failure handling with exit codes

A report that fails silently is worse than no report. The CLI gives you stable exit codes so a script can react to each failure type.

Exit codeMeaningWhat the script should do
0okcontinue
1runtime errorlog and continue, mark the file missing
2usage errorstop, a command in the script is wrong
3auth errorstop and alert: key revoked or creator lost access
4not foundlog, the campaign id may be wrong or deleted
5rate limitedwait, retry once, then log

These codes come from the CLI README. Note that the README says the codes were redefined in version 0.1.1, so any script written for the 0.1.0 scheme needs updating.

#Alerting on failure

Add a small notification step to the runner. It should fire when the exit code is non-zero or when the manifest is missing expected files.

bash
notify() { # REPORT_WEBHOOK_URL is your team chat's incoming webhook, stored in the env file. curl -fsS -X POST -H 'Content-Type: application/json' \ -d "{\"text\":\"$1\"}" "$REPORT_WEBHOOK_URL" > /dev/null } if [ "$RC" -eq 3 ]; then notify "Weekly outbound report: auth error. Check the reporter key." elif [ "$RC" -ne 0 ]; then notify "Weekly outbound report finished with exit code $RC. See cron.log." fi

Store the webhook address in the env file with the key, since anyone who holds it can post to your channel. Treat it as a secret.

#The silent failure to design against

The worst failure is the job not running at all. Cron gives no warning if the machine was off or the crontab was lost.

Add a dead-man's check. On Tuesday morning, a second tiny job checks that a report file exists for Monday and posts a warning if not.

0 9 * * 2 test -f $HOME/outbound-reports/reports/$(date -d yesterday +\%F).txt || echo "Weekly report missing" | mail -s "Report missing" [email protected]

The date -d yesterday syntax works with GNU date, which most Linux servers use. On macOS, use date -v-1d +%F instead.

#Optional narration by a model

You now have a text file of changes. If your readers prefer prose, hand that file to a model and ask for a summary.

There are two routes using tools already in this series. The first is the CLI's own Copilot command from the README.

bash
firstsales copilot ask "Summarize this week's campaign performance" \ --org "$ORG_ID" --workspace "$WORKSPACE_ID" --json

The README says this posts a prompt, polls the session, and prints only the assistant's reply text on standard output, so it is safe to pipe. Progress and approval prompts go to standard error.

Note the difference. This route asks FirstSales' own assistant to read the live data, so the answer is not based on your diff file. For a report where numbers must match your snapshots, the second route is better.

The second route pipes your diff into a Claude Code non-interactive run. The Claude Code headless documentation shows the pattern of piping content into claude -p with a prompt.

bash
cat ~/outbound-reports/reports/2026-10-12.txt | claude -p \ "Write five plain sentences summarizing this week-over-week diff for an outbound manager. Use only numbers present in the input. Flag any bounce increase."

Give that run no tools. It needs only to read standard input and write text, and a model with no tools cannot change anything.

#Rules for the narration step

Keep the narration separate from the numbers. Save the prose in a different file from the diff, and label it as machine written.

Do not feed raw reply text to the model. Reply bodies are written by strangers, and the diff file contains only numbers and paths, which keeps the prompt clean.

If you ever add reply content to the report, apply the cautions from the daily standup post about untrusted text. The same lethal trifecta logic applies to a weekly job.

#Compare prose to numbers

Once a month, read one narrated report next to its diff. Check that every number in the prose appears in the input.

If a number does not appear, the model invented it. Tighten the prompt and consider dropping the narration step.

#Reading the report: what to act on

A diff file is only useful if you know what to look for. Here is how to read one in five minutes.

Start with the MISSING lines. A field that vanished or appeared is either an API change or a collection problem, and both need a look before you trust the rest.

Then scan the bounce-related paths for increases. A rising bounce count is the earliest warning you get, and it points to list quality or sender health.

Then look at replies. A drop in replies with steady sends points to copy or targeting, while a drop in sends with steady replies points to a capacity or pause problem.

Finally, read the billing credits file against last week's. Credit movement should match your send volume, and a surprise there means something ran that you did not expect.

#When the report says act

If bounces rose two weeks in a row, pause the affected campaign and investigate. The start, pause and resume campaigns from the CLI post shows the safe sequence.

If you suspect a deliverability incident, the deliverability incident response playbook walks through the first hour. The weekly report gives you the evidence, and the playbook tells you what to do with it.

For ongoing sender health, email deliverability monitoring covers the signals the report cannot see. Use the two together.

#What the report cannot tell you

A snapshot diff shows what changed, not why. It cannot tell you that a competitor ran a similar offer or that a holiday week slowed replies.

If replies dropped, read the reply handling playbook before you blame the copy, and check AI SDR prompt drift if an agent writes your messages.

Add a short human note to each week's report file. Two sentences about context make the archive far more useful a year later.

#Retention, audit trail and multiple workspaces

Snapshots are cheap, so keep them. A year of weekly folders is small, and it gives you a record nobody can edit after the fact.

Make the snapshot folders read-only after each run if your team wants stronger assurance.

bash
chmod -R a-w ~/outbound-reports/snapshots/$(date +%F)

#Back them up

Copy the reports folder to storage that is not on the same machine. A report archive on the only disk of a server that dies is no archive.

Do not back up the env file to shared storage. It holds a live key.

#Several workspaces

If you run more than one workspace, give each its own env file, key, campaign list and folder tree. Parameterize the scripts with a profile name.

bash
ENV_FILE="$HOME/.config/firstsales-report-$1.env" ROOT="$HOME/outbound-reports/$1"

One key per workspace follows the least-access idea from API keys for outbound agents, and a leak affects one workspace only. The CLI also supports named profiles via --profile or FIRSTSALES_PROFILE, which the README recommends for developer machines and not for CI.

#A shared place to read the report

Teams read reports where they already work. Post the diff file or a link to it in your team chat, and keep the archive in a shared folder with access limited to your outbound team.

For the full terminal workflow that surrounds this job, see running outbound from the terminal.

#Comparing reporting approaches

This table compares the common ways teams produce a weekly outbound report.

ApproachReproducibleNumbers from a scriptArchive over timeSafe to run unattended
Screenshots pasted into a document✗✗✗✗
Manual export from the app each Monday✗✓✓✗
Agent chat that summarizes on request✗✗✗✗
Cron plus CLI snapshots plus diff (this post)✓✓✓✓
Cron plus CLI plus diff plus model narration✓✓✓✓

The last two rows differ only by the narration step. The table scores them the same because the narration never touches the numbers.

#Where each approach fits

Manual exports are fine for a team that reports monthly and has one campaign. Once you run several campaigns, the hour you save each week pays for the script in a month.

The agent-chat approach is useful for questions in the middle of the week. The campaign analytics in Claude post covers it, and it complements the weekly record without replacing it.

#Test the job before you trust it

A scheduled job needs a test, because you will not be watching when it runs. Do these checks once, in this order.

First, run the collection script by hand and read the log file. Confirm every expected file exists in the manifest.

Second, run it twice on the same day and confirm the second run does not corrupt the first. The folder is dated, so a second run overwrites the same files, which is acceptable for a test.

Third, break the key on purpose. Set a wrong value in a copy of the env file and run the script, then confirm you get the auth alert and a non-zero exit code.

Fourth, schedule it for five minutes from now with a temporary cron line, and confirm it runs under cron's minimal environment. This catches PATH problems that a hand run hides.

Fifth, run the diff on two real folders and read the output end to end. Add noisy paths to the ignore file until the diff shows only real changes.

#Use --dry-run when you add write steps later

The collection script is read-only, so --dry-run is not needed for it. If you later extend the job to act, for example to pause a campaign on a bounce spike, preview each write with --dry-run first.

The README describes --dry-run as printing the method, URL, redacted headers and body without sending the request. Treat any write added to a scheduled job with extreme care, and prefer to keep reports and actions in separate jobs with separate keys.

#FAQs

#What is a weekly campaign report with cron and the CLI?

It is a scheduled job that calls the FirstSales CLI once a week, saves the JSON output into dated folders, and diffs this week against last week. The numbers come from a script, so the report is reproducible.

#Why not let an AI agent write the whole report?

Agents are unreliable at arithmetic over long JSON and rarely say when they drop a row. Letting a script produce the numbers and a model write only the prose keeps the figures correct even if the prose fails.

#Which CLI commands does the report use?

It uses whoami, connectors list, alerts list, campaigns progress, campaigns analytics, campaigns events and billing credits. All of them read state and none is destructive.

#Do I need a special API key for the report?

Yes, create a dedicated key named for the job with read scopes only. The README example uses campaigns:read, and the authentication docs list the other scope names you may need.

#What happens if the key creator leaves the company?

The key stops working, because keys fail closed if the creator loses organization access or is suspended. Create report keys from a stable account and set a review reminder.

#How do I avoid hard-coding JSON field names?

Flatten each file into path and value lines with jq, then compare the lines with join and awk. The diff then works for any shape and shows new or missing fields automatically.

#How do I handle fields that change every run?

Add a regular expression per line to an ignore file for each noisy path, such as a timestamp. After two or three weeks the list stops changing.

#What time should the cron job run?

Run it before your team's weekly planning meeting. Convert the time to the server's zone, since cron uses the machine's system time zone.

#Why does the job work by hand but fail under cron?

Cron runs with a minimal environment and does not load your shell profile. Source the env file in the script, use full paths, and set PATH in the crontab.

#What exit codes should the script handle?

0 ok, 1 runtime error, 2 usage error, 3 auth error, 4 not found and 5 rate limited. Alert on 3, retry once on 5, and log the rest.

#How do I know if the job never ran?

Add a second job on the next morning that checks for the report file and posts a warning if it is missing. Cron itself raises no alarm when a run is skipped.

#Can I add a model summary to the report?

Yes, pipe the finished diff into a non-interactive Claude Code run or use firstsales copilot ask. Keep the prose in a separate file and give the model no tools.

#Does firstsales copilot ask use my snapshots?

No, it asks FirstSales' assistant to read live data, so its answer may differ from your saved snapshots. Use the piped-diff route when the prose must match the archived numbers.

#Should the report include reply text?

Avoid it. Reply bodies are written by strangers and can carry instructions aimed at a model, so a report built from counts is safer to feed to one.

#Can the job pause a campaign automatically?

It can, but we recommend against it. Keep reporting and acting in separate jobs with separate keys, and let a human decide on pauses.

#How long should I keep the snapshots?

Keep them for at least a year, since they are small and give you an audit trail. Back them up off the machine and keep the env file out of the backup.

#How do I run the report for several workspaces?

Give each workspace its own env file, key, campaign list and folder tree, and pass a profile name to the scripts. One key per workspace limits the damage of a leak.

#Do I need --confirm anywhere in this job?

No. --confirm is for destructive commands, and this job contains none; if you see yourself adding it, move that step out of the report.

#Which metrics should I put on the report first?

Replies and bounces for each live campaign, then sender health, then credits. Skip open rate, since it is noisy and rarely changes a decision.

#Where do I go if a command in the script fails?

Check the exit code and the log file first. Then run the command by hand with --pretty, and if the CLI returns unsupported_operation, the capability is not public and the job should not depend on it.

#Conclusion

A weekly campaign report earns trust when you can regenerate it. Cron plus the FirstSales CLI gives you that: a dated snapshot, a diff, and a log that shows exactly what ran.

Here are the takeaways to keep.

  • Keep every number in the report out of a model's hands. A script collects and compares, and a model may narrate afterwards.
  • Create a dedicated read-only key for the job, from an account that will stay, and keep it in a file only you can read.
  • Look at the real JSON once, and let the flatten-and-join diff handle any shape rather than guessing field names.
  • Run whoami first and stop on a mismatch, and handle exit codes 3 and 5 on purpose.
  • Add a next-morning check so a skipped run does not go unnoticed.
  • Keep reporting and acting in separate jobs, and never put delete commands in a scheduled job.
  • Before any campaigns start, run campaigns readiness and campaigns send-test-email.

Start with one campaign and one week. Once the archive has a month of folders, the diff starts to tell you things your memory would not.

Pair this with the daily outbound standup prompt for the day-to-day view, and read CLI, MCP or the app to decide which surface to use for each reporting job. To run it all on a platform built for agent-safe automation, try FirstSales.

Keep reading

Outreach that keeps going after you log off.Leads, emails, follow-ups and replies, handled.

Connect unlimited mailboxes and let AI agents find leads, write each email and follow up. Or send your own templates in manual mode. From $29/mo.

$1 for 3 days · Cancel anytime · No contract

Install with AI

Your coding agent sets up the FirstSales CLI and skills for you.

Prompt for Claude Code

I'm setting up FirstSales (https://firstsales.io), an AI outbound sales platform: campaigns, contacts, a reply inbox, deliverability and lead signals. Help me get my workspace sending real, good emails end to end, from Claude Code.

Read these first so you know how FirstSales works:
- Developer docs and API: https://developer.firstsales.io
- CLI: https://www.npmjs.com/package/@firstsales.io/cli
- Skills for coding agents: https://github.com/firstsalesio/firstsales-skills (read README.md and GUIDE.md)

Setup
1. Install the CLI: npm install -g @firstsales.io/cli
2. Install the FirstSales skills:
   claude plugin marketplace add firstsalesio/firstsales-skills
   claude plugin install firstsales@firstsales
3. Ask me if I already have a FirstSales account. If not, send me to https://app.firstsales.io to sign up.
4. Ask me to create an API key under Settings → API → API Keys, with only the scopes we need.
   Never ask me to paste the key into this chat. Tell me to run this in my own terminal, so the key stays hidden:
   read -rs FS_KEY && firstsales auth login --api-key "$FS_KEY"; unset FS_KEY
5. Run `firstsales whoami` and tell me which workspace and organisation we are connected to.

Then ask me about my business: what I sell, who I sell to (roles, company size, countries), my best customers, and what I use today for outbound (tools, lists, mailboxes). Walk me through:
1. Teaching FirstSales my business: website, offer, proof points and case studies in the knowledge base, so the AI writes like someone who knows the product.
2. Connecting my sending mailboxes and checking my sending domain: SPF, DKIM, DMARC and warm-up. If anything is unhealthy, tell me the exact DNS record to add.
3. Bringing in contacts: import my CSV or CRM export into a clean list, remove duplicates and bad emails, and block domains I must never email (customers, competitors, my own).
4. Building my first campaign: the right list, senders, steps and timing. Write the sequence with me, show me the drafts, and do not launch until I say yes.
5. Setting up Signals if it fits: find people engaging with LinkedIn posts about topics my buyers care about, and turn them into leads.
6. Inviting my team and giving each person the right access.

Once campaigns run, show me how to work the reply inbox from here: which replies need an answer, draft responses in my voice, and move good conversations into deals. If I use Cal.com, connect it so follow-ups can offer my booking link (not the first email).

Throughout, as you learn about my business, proactively suggest and help create:
- Reply templates for the questions and objections my prospects are likely to raise, based on my product, site and codebase if you can see them.
- Campaign ideas and segments worth testing, with the reason for each.

Safety rules:
- Show every change as a dry run (--dry-run) first and wait for my yes. Never send emails, launch campaigns or delete anything without my yes.
- Tell me before anything that spends credits, and how many.

Be opinionated. If the docs give a sensible default, just use it. Don't make me choose between five options.