Optimize before you send
Put the Message Optimizer between the code that writes an outbound draft and the queue that sends it, with no data connected: where the call goes, the fallback when it cannot answer, what to store, and what never to do with the result.
Level: production. Call: messages.optimize. You need: a Customer agent key kept server-side (amdahl keys create --preset agent, see CLI; rotate it before it expires), and code that writes or queues outbound messages. No connected data.
If your outbound comes out of code (an AI SDR, a job that fills a template and calls a model, an integration that queues sequencer steps), the Optimizer belongs in one place: after the draft is final and before it is queued. Every message then goes out either rewritten or confirmed as it was, and nothing waits on Amdahl to send.
Where the call goes
Write the draft
Your model, template or rep produces the final text, merge fields included.
your code
Optimize
One call per draft, in a background job. Usually under 30 seconds.
messages.optimize
Queue
The returned message, or your own draft whenever the call could not answer.
your sequencer
Three rules make it safe:
- Never block a send on it. Run it in a background job or a step of your workflow engine, with a timeout of 180 seconds. A call can take up to 170 before it gives up.
- Always have a fallback. When
okisfalse, or the call times out, queue your own draft (or hold it, if your process prefers that) and log why. - Use
messageas returned. It is the whole result: on a kept draft it is your own text, on a rewrite it is the version that passed the checks. Do not run it through another model to polish it, and do not merge it with your own rewrite. That throws away the checks that kept your names, numbers and merge fields exact. If it needs to change, send the change as a rule and run it again (see Steering it).
The call, with its fallback
TypeScript (Node 18 or later):
const API = 'https://app.amdahl.ai/api/platform/v1/messages/optimize'
/** What to queue, and why. */
export interface Optimized {
text: string
outcome: 'rewritten' | 'kept' | 'fallback'
runId?: string
reason?: string
}
/** Optimize one draft. Never throws: on any failure it returns the draft. */
export async function optimizeBeforeSend(
draft: string,
channel?: 'email' | 'linkedin',
): Promise<Optimized> {
try {
const res = await fetch(API, {
method: 'POST',
headers: {
'X-API-Key': process.env.AMDAHL_KEY ?? '',
'Content-Type': 'application/json',
},
body: JSON.stringify(channel ? { message: draft, channel } : { message: draft }),
signal: AbortSignal.timeout(180_000),
})
const body = await res.json().catch(() => ({}))
const data = body.data ?? body
if (!res.ok || !data.ok) {
return { text: draft, outcome: 'fallback', reason: data.reason ?? data.error?.code ?? `http_${res.status}` }
}
return {
text: data.message,
outcome: data.unchanged ? 'kept' : 'rewritten',
runId: data.run_id,
}
} catch (err) {
return { text: draft, outcome: 'fallback', reason: err instanceof Error ? err.name : 'unknown' }
}
}Python (3.9 or later, standard library only):
import json, os, urllib.error, urllib.request
API = "https://app.amdahl.ai/api/platform/v1/messages/optimize"
def optimize_before_send(draft, channel=None):
"""Optimize one draft. Never raises: on any failure it returns the draft."""
body = {"message": draft, **({"channel": channel} if channel else {})}
req = urllib.request.Request(
API, data=json.dumps(body).encode("utf-8"), method="POST",
headers={"X-API-Key": os.environ["AMDAHL_KEY"], "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=180) as res:
data = json.load(res)["data"]
except urllib.error.HTTPError as err:
return {"text": draft, "outcome": "fallback", "reason": f"http_{err.code}"}
except (OSError, ValueError, KeyError) as err: # timeout, network, or an unreadable body
return {"text": draft, "outcome": "fallback", "reason": type(err).__name__}
if not data.get("ok"):
return {"text": draft, "outcome": "fallback", "reason": data.get("reason")}
return {
"text": data["message"],
"outcome": "kept" if data.get("unchanged") else "rewritten",
"run_id": data.get("run_id"),
}Both return the text to queue and an outcome to log. Neither retries: an optimize has no idempotency key, and every attempt is a fresh run that counts toward the monthly cap. If a draft is worth a second try, enqueue it once more on your side and remember that you did.
What to store
For each message, keep enough to answer "what went out, and why" later:
| Store | From | Why |
|---|---|---|
| The draft and the text you queued | your code, message | The before and after. |
outcome | ok, unchanged | How often drafts are rewritten, kept or fall back. A rising fallback rate is your signal to look. |
run_id | the response | Quote it to Amdahl support about any one message. |
summary | the response | One line a person can read in a review queue. |
kept_suggestions[].says | the response, on a kept draft | Edits for whoever owns the template or the prompt. The same suggestion on every send is a template problem. |
ask[].asks | the response, on a draft too weak to rewrite | Questions for whoever wrote the draft. Nothing was rewritten, so the draft is worth a person's look before it goes out. |
notes[].code | the response | Facts about the run, such as style_pass_only or rewrite-rejected-for-entity-loss. Branch on code, never on the wording of says. Show a person in the review queue only the notes whose audience is sender, and keep the developer ones in your logs. |
A person in the loop, then without one
Start with a person approving each change: show the draft and message side by side in whatever review queue you already have, and queue the version they pick. A review screen may also send "include_tries": true to show every version the run scored on the way (see Reading tries); leave it out of the send path, which only needs message. Once a campaign's rewrites are going out unedited, let that campaign queue message on its own and keep reviewing a sample.
Three things to know before you take the person out:
- It is a style pass. With no data connected the Optimizer judges how a message reads and how clearly it asks. It does not check that its claims are true, so a wrong claim in the draft is still wrong in the rewrite, and every response says so in a
style_pass_onlynote. If your writer makes claims about customers or results, connect your conversations and read Check claims against your customers first. - The ask may change. The default pass prefers to close on a one-line question about whether the problem is real for the reader rather than a meeting request. If a campaign must end on the meeting ask, send it as a rule (below).
- Exact details stay exact. Names, numbers, dates, links and merge fields such as
{{first_name}}come back as written. A rewrite that changes one is discarded, so a template's placeholders survive and you can optimize the template once rather than every rendered copy.
Steering it
Send context with the call to hold the rewrite to the sender's rules and voice:
{
"message": "Hi {{first_name}}, ...",
"channel": "email",
"context": {
"rules": ["Never mention pricing.", "End by asking for a 15-minute call."],
"voice_examples": ["Hey Morgan, congrats on the new role. Most RevOps leads I talk to spend their first month untangling the CRM. Free for 15 minutes Tuesday?"]
}
}rules bind the rewrite: one that breaks a rule is never returned, and a kept draft gets no suggestion about a part of the message that a rule names. voice_examples are matched, not copied. Load both once per sender or campaign rather than writing them per send. The details, and facts for what a sender knows first-hand, are in Keep your voice, rules and facts.
Volume and the cap
- 1,000 a month per workspace for calls made with an API key or a connected agent, counted only when a result comes back. Past it the call returns
429withquota_exceededuntil the month rolls, and your fallback sends the drafts as they were. Ask Amdahl to raise it before a launch that will need more. - One draft per call. There is no batch endpoint. For a backlog, see Optimize a campaign.
- Watch the cap.
GET /setup/statusreturnsoptimize.quota(limit,used,remaining,resets_at) andoptimize.allowed. Call it from a health check or a deploy step, not on every send, and alert beforeremainingreaches zero. See Check your setup. - 30 optimize calls a minute per key, inside 300 requests a minute per client IP. A send pipeline that runs one optimize at a time rarely gets near it, since each takes seconds; a pipeline that runs many in parallel can. See Rate limits.
If your writer is an agent
When the drafts come from an agent over MCP rather than from your code, the same rules apply through the messages tool. Tell the agent, in its instructions, to queue the tool's message exactly as returned and never to rewrite it. An agent that paraphrases the result before using it undoes the checks that produced it. If the result needs to change, the agent re-runs the tool with the change as one of the rules in context.
Every field the response can carry is on Message Optimizer.