Optimize a campaign
Every step of an outbound campaign through the Message Optimizer from a spreadsheet, with no data connected: a script that reads a CSV of drafts, writes the before and after side by side, survives being stopped, and stays inside the monthly cap.
Level: a script. Call: messages.optimize, once per draft. You need: a Customer agent key in AMDAHL_KEY, Python 3.9 or later, and your drafts in a CSV. No connected data.
The fastest way to judge the Optimizer is on your own outbound, not on a sample. Export the steps of one campaign (or the drafts your AI SDR queued for next week) to a CSV, run them through, and read the before and after side by side. Nothing is sent and nothing in your tools changes: the output is a second CSV.
Your drafts as a CSV
One row per message. id is yours (a step name, a row number, a sequence id), draft is the message exactly as it would go out, and channel is optional. A drafts.csv with two steps:
id,channel,draft
step-1,email,"Hi {{first_name}},
I hope this finds you well! I'm reaching out because {{company}} is growing fast and I imagine onboarding new reps is taking up a lot of your managers' time.
Rampwise gives every new rep a guided first 30 days, so managers spend less time shadowing calls and more time coaching.
Would you be open to a quick call next week to see if it could help {{company}}?
Thanks,
{{sender_first_name}}"
step-2,linkedin,"Hi {{first_name}}, thanks for connecting! ..."- Keep your merge fields. Placeholders such as
{{first_name}}come back exactly as written, and so do names, numbers, dates and links: a rewrite that drops or changes one is thrown away. Export the template, not a rendered copy for one recipient, and the rewrite drops straight back into your sequencer. channelisemailorlinkedin. It sets the length and format a message is judged against. Leave it empty and it is inferred from the draft.
The script
It uses only the Python standard library. It sends one draft at a time, appends each result to optimized.csv as it lands, and skips any id already there, so you can stop it and start it again without paying twice. Save it as optimize_campaign.py:
#!/usr/bin/env python3
"""Optimize every draft in a CSV with the Amdahl Message Optimizer.
Usage: AMDAHL_KEY=amdhl_... python3 optimize_campaign.py drafts.csv optimized.csv
Input columns: id, draft, and optionally channel (email or linkedin).
Re-runnable: an id already in the output file is skipped.
"""
import csv, json, os, sys, time, urllib.error, urllib.request
API = "https://app.amdahl.ai/api/platform/v1/messages/optimize"
KEY = os.environ["AMDAHL_KEY"]
SRC = sys.argv[1] if len(sys.argv) > 1 else "drafts.csv"
OUT = sys.argv[2] if len(sys.argv) > 2 else "optimized.csv"
# Applied to every draft. Leave a list empty, or fill it from your style guide.
CONTEXT = {"rules": [], "voice_examples": []}
COLUMNS = ["id", "channel", "outcome", "draft", "returned", "summary",
"edits", "questions", "notes", "reason", "run_id"]
def post(body):
"""POST one draft. Returns (HTTP status, parsed body, seconds to wait on a 429)."""
req = urllib.request.Request(
API, data=json.dumps(body).encode("utf-8"), method="POST",
headers={"X-API-Key": KEY, "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=180) as res:
return res.status, json.load(res), 0
except urllib.error.HTTPError as err:
raw = err.read().decode("utf-8", "replace")
retry_after = err.headers.get("Retry-After") or ""
wait = int(retry_after) if retry_after.isdigit() else 60
try:
return err.code, json.loads(raw), wait
except ValueError: # the per-IP limiter answers in plain text
return err.code, {"error": {"code": f"http_{err.code}", "message": raw}}, wait
def optimize(row):
body = {"message": row["draft"]}
if (row.get("channel") or "").strip():
body["channel"] = row["channel"].strip()
context = {k: v for k, v in CONTEXT.items() if v}
if context:
body["context"] = context
while True:
try:
status, payload, wait = post(body)
except OSError as err: # no answer within 180 seconds, or the network failed
return {"ok": False, "reason": f"no_answer: {err}"}
data = payload.get("data", payload)
error = data.get("error") if isinstance(data.get("error"), dict) else {}
code = error.get("code")
if code == "quota_exceeded":
sys.exit("Monthly Optimizer cap reached. Re-run next month; finished rows are kept.")
if status in (401, 403):
sys.exit(f"{status}: {error.get('message') or payload}. Mint a Customer agent key.")
if status == 429: # a rate limit, not the cap: wait and retry this draft
time.sleep(wait)
continue
if status != 200:
return {"ok": False, "reason": code or f"http_{status}"}
return data
fresh = not os.path.exists(OUT) or os.path.getsize(OUT) == 0
done = set()
if not fresh:
with open(OUT, newline="", encoding="utf-8") as f:
done = {r["id"] for r in csv.DictReader(f)}
with open(SRC, newline="", encoding="utf-8") as f:
rows = [r for r in csv.DictReader(f) if r["id"] not in done]
with open(OUT, "a", newline="", encoding="utf-8") as f:
out = csv.DictWriter(f, fieldnames=COLUMNS)
if fresh:
out.writeheader()
for n, row in enumerate(rows, 1):
r = optimize(row)
if not r.get("ok"):
outcome = "failed"
elif r.get("unchanged"):
outcome = "kept"
else:
outcome = "rewritten"
out.writerow({
"id": row["id"],
"channel": (r.get("extraction") or {}).get("channel") or row.get("channel", ""),
"outcome": outcome,
"draft": row["draft"],
"returned": r.get("message", ""),
"summary": r.get("summary", ""),
"edits": " | ".join(s["says"] for s in r.get("kept_suggestions") or []),
"questions": " | ".join(q["asks"] for q in r.get("ask") or []),
"notes": " ".join(note["code"] for note in r.get("notes") or []),
"reason": r.get("reason", ""),
"run_id": r.get("run_id", ""),
})
f.flush()
print(f"[{n}/{len(rows)}] {row['id']}: {outcome}")Run it:
export AMDAHL_KEY="amdhl_your_key_here"
python3 optimize_campaign.py drafts.csv optimized.csvCreate the key with amdahl keys create --name "Campaign" --preset agent (see CLI), or in the console with Access set to Customer agent. Before a long run, check that the key can optimize and that the month has room for every row:
curl -s "https://app.amdahl.ai/api/platform/v1/setup/status" -H "X-API-Key: $AMDAHL_KEY" | jq '.data.optimize'allowed must be true, and quota.remaining must cover the rows in your CSV. When allowed is false, blocker names the fix; see Check your setup.
Each draft usually takes under 30 seconds, so a 100-row campaign takes about 40 minutes. Start it and come back.
What it does on each outcome
| Outcome | What the script does | Why |
|---|---|---|
A rewrite (unchanged: false) | Writes it to returned with the summary | The returned text is the whole result. Keep it as is. |
Your draft, kept (unchanged: true) | Writes your draft to returned, the suggested edits to edits and any questions to questions | A kept draft is a real result, and on a weak draft the edits and questions are the work. |
ok: false | Writes the reason, moves on | Nothing was rewritten. optimizer_timeout and optimizer_error can be run again later. |
429 with quota_exceeded | Stops | The workspace used this month's API and connector optimizations (1,000 by default). Rows already written are kept. |
Any other 429 | Waits for Retry-After, retries that row | A rate limit, not the cap. |
401 or 403 | Stops | The key is wrong, expired, Read only, or older than the Optimizer, or your role is Viewer. GET /setup/status names which. |
| No answer in 180 seconds | Records it, moves on | It does not retry: every attempt is a fresh run. |
It runs one draft at a time on purpose. The answer comes back on the same call, so there is nothing to gain from a queue, and a long batch shares the Optimizer with everyone else's.
Read the results
Open optimized.csv in a spreadsheet and sort by outcome.
rewritten: readdraftandreturnedside by side. These are style rewrites: they make the message read better and ask more clearly. They do not check that its claims are true, so a claim that was wrong in the draft is still wrong in the rewrite. Thenotescolumn carriesstyle_pass_onlyon every row for that reason.kept: nothing tried beat the draft. Readedits: each one is an edit a person can make in seconds, such as a stock line to replace or a length to cut to. Make the ones you agree with and put those rows through again. A row with text inquestionswas too weak to rewrite: the sender answers those questions in the draft, then the row goes through again.failed: run the script again later; it only retries what is not yet in the file. To retry a failed row, delete its line fromoptimized.csvfirst.
Here is step 1 of the campaign above, as it came back in the returned column:
Hi {{first_name}},
Saw that {{company}} has been growing quickly, which usually means a wave of new reps to onboard and managers who suddenly have very little time left for anything else.
Rampwise gives each new rep a guided first 30 days, so managers spend less time shadowing calls and more time actually coaching.
Is that the kind of thing you're running into right now?
Thanks,
{{sender_first_name}}The merge fields and the "30 days" survived, the stock opener did not, and the meeting request became a one-line question. The default pass prefers that kind of close. If your campaign has to end on the meeting ask, put that in CONTEXT["rules"] and run again; see Keep your voice, rules and facts.
Put the approved ones back
Copy the returned text of the rows you approve back into the same steps in your sequencer, or import the columns. Use it as it came back: do not polish it with another model, which undoes the checks that kept your merge fields and details exact. To change a row, add the change to CONTEXT["rules"] and run that row again, or edit it by hand. Nothing in the script writes to your tools, and nothing should until a person has read the rows.
Budget
- The cap. Calls made with an API key or a connected agent count toward 1,000 optimizations a month per workspace, counted only when a result comes back. A 300-row campaign uses 300 of them. Calls from the console do not count.
- Time. About 25 seconds a draft is typical; one can take up to 170.
Without code
- Three to five drafts: ask your agent. Paste
Load and follow https://docs.amdahl.ai/skills/amdahl-optimizer/SKILL.mdinto Claude, ChatGPT, Codex or Claude Code with Amdahl connected. It finds upcoming drafts in the tools it can reach, including a spreadsheet, and shows each one before and after, with every try it scored on the way. - One draft: paste it into the Message optimizer box on the console's API Playground page.
Next: Optimize before you send puts the same call in front of every draft your code sends.