Docs

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

One draft, from your code to the send queue
  1. Write the draft

    Your model, template or rep produces the final text, merge fields included.

    your code

  2. Optimize

    One call per draft, in a background job. Usually under 30 seconds.

    messages.optimize

  3. Queue

    The returned message, or your own draft whenever the call could not answer.

    your sequencer

Three rules make it safe:

  1. 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.
  2. Always have a fallback. When ok is false, or the call times out, queue your own draft (or hold it, if your process prefers that) and log why.
  3. Use message as 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):

ts
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):

python
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:

StoreFromWhy
The draft and the text you queuedyour code, messageThe before and after.
outcomeok, unchangedHow often drafts are rewritten, kept or fall back. A rising fallback rate is your signal to look.
run_idthe responseQuote it to Amdahl support about any one message.
summarythe responseOne line a person can read in a review queue.
kept_suggestions[].saysthe response, on a kept draftEdits for whoever owns the template or the prompt. The same suggestion on every send is a template problem.
ask[].asksthe response, on a draft too weak to rewriteQuestions for whoever wrote the draft. Nothing was rewritten, so the draft is worth a person's look before it goes out.
notes[].codethe responseFacts 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_only note. 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:

json
{
  "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 429 with quota_exceeded until 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/status returns optimize.quota (limit, used, remaining, resets_at) and optimize.allowed. Call it from a health check or a deploy step, not on every send, and alert before remaining reaches 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.