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.
- What happened this week? Sends, replies, bounces and any events worth noting for each live campaign.
- How does it compare with last week? The same numbers, side by side, with the change.
- Is the sender side healthy? Connector status, warmup status and open alerts.
- What did it cost? Credit balance and usage for the period.
- 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.
bashnpm 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.
bashfirstsales 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.
bashmkdir -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.
bashcat > ~/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.
bashset -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.
bashcat >> ~/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.
bashflatten_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 code | Meaning | What the script should do |
|---|---|---|
| 0 | ok | continue |
| 1 | runtime error | log and continue, mark the file missing |
| 2 | usage error | stop, a command in the script is wrong |
| 3 | auth error | stop and alert: key revoked or creator lost access |
| 4 | not found | log, the campaign id may be wrong or deleted |
| 5 | rate limited | wait, 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.
bashnotify() { # 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.
bashfirstsales 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.
bashcat ~/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.
bashchmod -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.
bashENV_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.
| Approach | Reproducible | Numbers from a script | Archive over time | Safe 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
whoamifirst 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, runcampaigns readinessandcampaigns 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.