Docs

Message Optimizer

messages.optimize writes one outbound message from the context you send about the recipient, in the sender own voice, and returns it on the same call. A draft is optional: sent with facts or rules, it is the starting point. Graded on style, and against your own customer conversations when you pass evidence: workspace.

messages.optimize writes one outbound message from the context you send: who the recipient is, what you know about them, and what you want them to do. It writes in the voice of the person sending it, scores each version it tries, and returns the one that graded best.

A draft is optional. When you have one, send it in message with your context. The draft is then the starting point of the message. It is the sender's own word, so what it states may be stated. The message keeps its substance, order, layout and voice. Your facts add to it and your rules shape it. A draft sent with no facts, no rules and no recipient is improved instead, with a draft_improved_no_context note. Context says what to send and what counts as enough.

The quality of the message depends on the quality of the context. A run on one thin fact and no draft returns a thin message, because nothing else gives it substance. It needs no connected data, so it is the first result a new workspace can get, but connected data and recipient fields make the message specific. The cookbooks put it to work: one draft read end to end, a campaign from a spreadsheet, and the call in front of your send queue.

OperationRESTMCPScope
Write a messagemessages.optimizePOST /messages/optimizeoptimize_messagemessages:execute
Queue many messagesmessages.optimize_batchPOST /messages/optimize/batchoptimize_message_batchmessages:execute
Read a batchmessages.batch_statusGET /messages/optimize/batches/{id}get_message_batchmessages:execute
Read a past runmessages.get_runGET /messages/optimize/runs/{id}nonemessages:execute
Record feedback on a runmessages.record_feedbackPOST /messages/optimize/runs/{id}/feedbackrecord_message_feedbackmessages:execute
Grade or improve a promptmessages.optimize_promptPOST /messages/optimize_promptoptimize_promptmessages:execute
Read a prompt runmessages.get_prompt_runGET /messages/optimize_prompt/{id}get_prompt_runmessages:execute

For more than a handful of messages, use a batch: one request, a batch_id back at once, and no 429 for being busy.

To score a draft without rewriting it, send it with mode: "grade": see Grade a message. To grade or improve a reusable prompt that writes emails for many recipients, use messages.optimize_prompt: see Improve a prompt. Both replace the evals, which retired on 2026-10-22.

A Customer agent key carries messages:execute (amdahl keys create --preset agent). A key created before the optimizer went public does not: create a new one. A Read only key is refused with 403 forbidden.

What it grades

That depends on evidence, which you choose per call, and then on whether the workspace has synced conversations:

  • evidence omitted or "off" (the default): style alone. How the message reads (tone and length), how clear its ask is, and how close it is to the way you write. This is the fast pass, and it is what a brand-new workspace gets either way.
  • evidence: "workspace", on a workspace with synced conversations: style, and the message against your own customers' words. It is slower. Each grading reads about 20 quotes from the workspace's own conversations, and also scores four dimensions against them: relevant_positioning (is the offer positioned for this customer's situation rather than as a generic pitch), grounding (are its claims backed by what your customers actually said), verified_specifics (are its specific details ones your customers actually stated) and differentiation (does it make a distinct, evidence-backed point rather than boilerplate). The writer reads up to 8 of those quotes too, to ground the message in what your customers actually say; it never quotes them or presents them as the recipient's words. The blind re-grade behind lift reads the same quotes, and lift.quotes counts them.

With evidence off, those four dimensions are not graded at all: they drop out of the score rather than counting as zero, and nothing is read from your conversations. With "workspace" on a workspace that has no conversations yet, or in an environment that does not read them, the run is graded as with "off" and still succeeds; the response's evidence field says which happened. Pass "workspace" to keep the behaviour this operation had before evidence became a per-call choice.

The ask. On a style pass the Optimizer prefers a close the reader can answer in one line, asking whether the problem is real for them, over a request for a meeting. If a meeting ask is what you want, put it in rules, inside context, for example "Ask for a 15-minute call next week." A rule about the ask decides it: the message closes the way your rule says, and a version that breaks a rule is never returned.

With connected conversations it also lists the claims in the message that nothing in those conversations and none of your facts back, in unsupported_claims, and by default keeps them in the message rather than cutting them. Without connected conversations claims are not checked, and unsupported_claims is null.

The grade measures the message against your evidence and this rubric; it does not predict replies. To score a draft as it is, with no rewrite, use mode: "grade". A grade runs Amdahl's final judge once over the five rubric lines and writes nothing.

Monthly cap

Each call made with an API key or an external OAuth token that returns a result (ok: true) counts toward a monthly cap of 1,000 per workspace. A call that failed does not count. Past it, the call returns 429 quota_exceeded with the family, limit and usage in details, and the optimizer is not run. The cap resets at the start of the next month; ask Amdahl support to raise it. Calls from the Amdahl console do not count.

A batch counts the same way, one item at a time: each item that comes back with a result counts once, and an item that failed does not count.

Running several at once

Each workspace has two limits on the Optimizer. They count every call for the workspace together: every API key, every connector session and the Amdahl console.

LimitValuedetails.reason when refused
Optimizes running at the same time3workspace_in_flight_limit
Optimizes started in any 60 seconds12workspace_rate_limit

A call over a limit is not refused immediately. It waits in line for up to 15 seconds, and runs if a place frees in that time. A call from the MCP optimize_message tool waits for up to 2 minutes, because an agent in a chat cannot schedule a retry. If no place frees, the call returns 429 rate_limited, and the optimizer is not run:

json
{
  "data": {
    "ok": false,
    "success": false,
    "error": {
      "code": "rate_limited",
      "message": "This workspace already has 3 drafts being optimized, the most it may run at once. Nothing was rewritten. Wait 18 seconds, then send this draft again.",
      "details": {
        "reason": "workspace_in_flight_limit",
        "retry_after_seconds": 18,
        "limit": 3,
        "in_flight": 3
      }
    }
  }
}

The response carries a Retry-After header with the same number of seconds. Wait that long, then send the same request again. For workspace_rate_limit the wait is exact. For the other reasons it is an estimate of when a running call finishes, so a retry can be refused again: wait again. A workspace that already has six calls waiting gets the 429 immediately. A refused call does not count toward the monthly cap.

The Optimizer also has a limit across all workspaces. When it is full, a free place goes to the workspace with the fewest calls running, so one workspace's batch does not hold up another workspace's single call. A call that waits its full time without a place is refused the same way, with details.reason: "optimizer_busy" and no limit or in_flight. Handle all three reasons the same way.

When the Optimizer is down

When the Optimizer fails 5 requests in a row inside one minute, Amdahl stops sending calls to it for 30 seconds. A failed request is one that got no answer, or an answer with a 5xx status. A call in those 30 seconds is refused immediately: it does not wait in line and it does not reach the Optimizer. The refusal is the same 429 rate_limited, with details.reason: "optimizer_unavailable", and retry_after_seconds and Retry-After are the time that is left (30 seconds or less).

After the 30 seconds, one call goes through as a test. If the Optimizer answers it, all calls go through again. If it fails, calls are stopped for another 30 seconds. While the test call is running, other calls are refused with a wait of 5 seconds.

When you run a list of drafts and get optimizer_unavailable, stop the list. Wait retry_after_seconds, send one draft, and continue only when it succeeds. A refused call is not billed.

The first failures, before calls are stopped, come back as optimizer_error or optimizer_timeout (see When ok is false).

A batch is not refused. While the Optimizer is down its queued items stay queued, they do not fail, and the time does not use one of an item's 3 tries. They run when the Optimizer answers again.

The same request twice is one run. If you send a request identical to one that is still running (same sender, same draft, same options), you get the result of the first: the draft is not optimized twice and the second request uses no extra place. A repeat after the first finished returns the stored result. So it is safe to retry a call that timed out on your side.

To optimize a long list of drafts, do not loop over this call: send them as a batch. A batch is not held to these limits and is never refused as busy. For a few drafts, send them one at a time, or at most three at a time. Optimize a campaign has a script for each.

Batches: many drafts in one request

messages.optimize_batch takes a list of drafts and returns a batch_id at once. The drafts run later, each one exactly as a single messages.optimize does, and you read the results from the batch.

Use a batch when you have more drafts than the limits above let you run as single calls, or when nobody is waiting on each result. A 300-email campaign sent as single calls is mostly 429s. Sent as a batch, it is one 202.

How a batch is scheduled:

  • A batch is never refused as busy. The limits above (3 at once, 12 a minute, the 15-second wait) do not apply to it. Its items wait in a queue until there is a place for them.
  • Single calls go first. Batch items run only on Optimizer capacity that single calls are not using, and part of that capacity is never given to batches. So a running batch does not slow a single call, yours or another workspace's.
  • Batch items do not use your workspace's 3 at once or 12 a minute. You keep your whole allowance for single calls while a batch runs.
  • Workspaces take turns. When more than one workspace has a batch queued, the next free place goes to the workspace with the fewest batch items running. Another workspace's 300-item batch puts about 5 items ahead of your 5-item batch, not 300.

A batch therefore has no promised finish time. The status gives an estimate.

Submit a batch

bash
curl -s -X POST https://app.amdahl.ai/api/platform/v1/messages/optimize/batch \
  -H "X-API-Key: $AMDAHL_KEY" -H "Content-Type: application/json" \
  -d '{
    "idempotency_key": "q4-launch-step-1",
    "items": [
      { "item_id": "lead-001", "channel": "email",
        "context": { "facts": ["Sam Ortiz is Head of RevOps at Brightpath.", "Brightpath moved to Salesforce in August."],
                     "rules": ["Ask for a 15-minute call."] } },
      { "item_id": "lead-002", "channel": "email",
        "context": { "facts": ["Priya Shah is VP Sales at Northwind Labs.", "Northwind is hiring six SDRs this quarter."],
                     "rules": ["Never mention pricing."] } }
    ]
  }'
FieldRequiredWhat it is
itemsYes1 to 500 messages. Each item is the body of one single optimize, with every field it takes and the same limits and defaults as in Request: context, message, channel, evidence, on_unsupported, on_missing_input, include_tries, and the recipient fields. A field a single call refuses is refused in an item for the same reason, with the item's path ($.items[3].channel). Results come back in this order.
items[].item_idNoYour own id for the item, up to 200 characters, unique within the batch. It comes back with the item's result, so you can match results to rows without counting.
idempotency_keyNoUp to 200 characters. Send it to make a retry safe: see below.
webhook_urlNoREST only. An https URL that gets one signed POST when the batch completes: see Completion webhook.

An unknown field in the body or in an item is refused with 400, and the error names its path ($.items[3].chanel). The recipient fields work in an item as on a single call. Each item's recipient is resolved and briefed when the item runs, and its result carries the same recipient_received, evidence_run_id, profile and brief a single call returns. A valid recipient field is accepted on every workspace and is never named in ignored_params. In a batch sent with X-Amdahl-As-Of, an item's recipient is refused with 400 invalid_input on every workspace: the batch does not keep the as-of time, so it cannot build the item's brief at that time. Send that draft as a single call. The whole request body is limited to 1 MB, so a campaign with many facts or long voice examples may need more than one batch.

The answer is 202 Accepted:

json
{
  "data": {
    "ok": true,
    "replayed": false,
    "batch_id": "0d0f8c0e-6f0b-4a53-9a43-2f6d1f1f5b0a",
    "status": "queued",
    "created_at": "2026-10-14T09:00:00.000Z",
    "completed_at": null,
    "counts": { "total": 300, "queued": 300, "running": 0, "done": 0, "failed": 0 },
    "queue_position": 0,
    "eta": {
      "seconds": 3215,
      "estimate": true,
      "items_ahead": 0,
      "median_run_seconds": 75,
      "slots": 7
    }
  }
}

When an item has evidence: "workspace" and the workspace has no connected source, the answer also carries setup_needed with a message and a connect_url for the user. The batch is still queued, and those items are rewritten without customer evidence. Each of those items also carries setup_needed in its result, as a single call does. See A workspace with nothing connected.

Retrying a submit. If you send idempotency_key and the request times out on your side, send the same request again. The same key with the same items and webhook_url returns the first batch with replayed: true and queues nothing new. The same key with a different body is refused with 409 conflict. A key belongs to one user of one workspace. Without a key, every submit is a new batch.

What can refuse a batch. Never capacity. Each of these queues nothing:

Statuserror.codeWhenWhat to do
400invalid_inputThe body does not validate. error.details.errors lists each path.Fix the request.
429quota_exceededThe batch is larger than what is left of the monthly cap, after the items you already have queued. error.details carries limit, used, pending, requested and remaining.Send at most remaining drafts, or ask Amdahl to raise the cap.
429rate_limited, with details.reason: "batch_backlog_limit"The workspace would have more than 5,000 batch items queued or running.Wait for an earlier batch to finish. There is no Retry-After: a short wait does not fix it.
409conflictThe idempotency_key was used for a different body.Use a new key for a new batch.
503optimizer_unconfigured, batch_unavailableThe Optimizer, or the batch queue, is not available in this environment.Contact Amdahl.

Read a batch

bash
curl -s "https://app.amdahl.ai/api/platform/v1/messages/optimize/batches/$BATCH_ID?status=done&limit=100" \
  -H "X-API-Key: $AMDAHL_KEY"
QueryWhat it does
limitItems per page, 1 to 100. The default is 50.
cursorThe next_cursor of the page before. Omit it for the first page.
statusReturn only items in one state: queued, running, done or failed. Omit it for every item, in the order you sent them.
wait_msHold the read until the batch is completed, for up to this many milliseconds (at most 45000), then return the same body. Omit it to read at once.
only_finishedtrue returns only done and failed items, with finished_cursor and finished_remaining. limit applies; cursor and status are ignored.
finished_sinceThe finished_cursor of an earlier read. Returns only the items that finished after that read. Implies only_finished.
json
{
  "data": {
    "batch_id": "0d0f8c0e-6f0b-4a53-9a43-2f6d1f1f5b0a",
    "status": "running",
    "created_at": "2026-10-14T09:00:00.000Z",
    "completed_at": null,
    "counts": { "total": 300, "queued": 251, "running": 7, "done": 41, "failed": 1 },
    "queue_position": 0,
    "eta": {
      "seconds": 811,
      "estimate": true,
      "items_ahead": 0,
      "median_run_seconds": 22,
      "slots": 7
    },
    "items": [
      {
        "index": 0,
        "item_id": "lead-001",
        "status": "done",
        "result": { "ok": true, "outcome": "rewritten", "rewrite": "Hi Sam, ...", "message": "Hi Sam, ...", "summary": "...", "unchanged": false }
      },
      {
        "index": 1,
        "item_id": "lead-002",
        "status": "failed",
        "result": { "ok": false, "reason": "optimizer_timeout", "detail": "The message optimizer did not answer within 300s. Nothing was rewritten." }
      },
      { "index": 2, "item_id": "lead-003", "status": "queued", "result": null }
    ],
    "next_cursor": null
  }
}
FieldWhat it is
statusqueued (no item has started), running, or completed (every item has settled). A completed batch can hold failed items: read counts.failed.
countsHow many items are queued, running, done and failed, and the total.
queue_positionAn estimate: how many queued items, in any workspace, are expected to start before this batch's next one. 0 when none of its items is waiting.
etaAn estimate of the seconds until the last item finishes. null once nothing is left to run.
items[].indexThe item's place in the list you sent, from 0.
items[].item_idYour own id for it, or null if you sent none.
items[].statusqueued, running, done or failed.
items[].resultnull until the item has run. Then it is exactly what a single optimize returns for that draft, with every field in Response: read a batch item and a single call with the same code.
next_cursorSend it as cursor for the next page. null on the last page, and always null with only_finished.
finished_cursorWith only_finished: every finished item returned so far. Send it as finished_since on the next read.
finished_remainingWith only_finished: finished items not returned yet because of limit. Read again with finished_since to get them.

A failed item has ok: false with a reason and a detail, as in When ok is false. The reasons are optimizer_timeout, optimizer_error and optimizer_unconfigured, and one more: quota_exceeded, when the workspace reached its monthly cap before that item ran. An item the Optimizer could not finish is tried again later, up to 3 times in all, before it fails. A try while the Optimizer is down is not one of the 3: the item goes back in the queue. A failed item has no retry_after_seconds or stop_batch, which a single call's optimizer_error and optimizer_timeout carry: the batch already retried an optimizer_error or optimizer_timeout item, and its other items keep running. The recipient fields a batch accepted are never ignored. If stored recipient fields no longer pass the recipient checks when the item runs, the item fails with invalid_input; generate: true fails with not_available. The batch does not retry the other reasons. A failed item with recipient fields keeps what a failed single call returns: recipient_received, evidence_run_id and, with recipient, profile and brief. A draft that was kept is done, not failed: read outcome. An item that sent on_missing_input: "ask" and whose input cannot support the request is done too, with outcome: "needs_input" and the questions in ask, and it does not count toward the cap: add the answers as facts and send that draft again.

Who can read it. The user that submitted the batch, with a credential that has messages:execute. Any other user, any other workspace and an unknown id all get the same 404.

How often to poll. Send wait_ms (at most 45000, and below your client's own timeout) and read again when the call returns: the read answers as soon as the batch completes, so there is no faster loop to write. If the server is already holding as many reads as it can, the read returns at once with a wait_note; wait a few seconds before the next one. A REST client that disconnects, or an MCP client that cancels the call, ends its hold. An unreadable finished_since counts as no cursor, so every finished item comes back at once. A status read is in the read rate class and is not held to the Optimizer limits. To collect results as they finish, send only_finished=true on the first read and then finished_since set to the last finished_cursor: the read also returns as soon as a new item has finished, and each item comes back once, also when items finish out of order. Paging status=done with cursor can skip an item that finishes after a later one.

How long results are kept. 30 days after the batch completes. Then the batch and its results are deleted.

The estimate

queue_position and eta are estimates, and eta.estimate is always true. Nobody knows how long a run will take, or how many single calls will arrive and go first.

  • median_run_seconds is the median run time of the last 50 batch items that finished. Before any has finished, it is 75 seconds, the measured median of a single optimize.
  • items_ahead is how many items are expected to run before this batch's last item, not counting its own. It is your workspace's items in older batches, plus the items of other workspaces that take turns with yours.
  • slots is how many batch items can run at once.
  • seconds is median_run_seconds times (items_ahead plus this batch's queued and running items), divided by slots.

Expect the real time to be longer when many single calls are running, and treat the number as a guide for when to poll, not a deadline.

Completion webhook

Send webhook_url and Amdahl sends one POST to it when the batch completes, so you do not have to poll.

  • The URL must be https, must name a public host, and must carry no username or password. A redirect is not followed.
  • The body carries the batch id and its counts. It never carries a draft or a rewrite: read those from the batch with your key.
  • The submit's 202 carries the signing secret, once, in webhook_signing.signing_secret. Store it. No later read returns it. (A replayed submit with the same idempotency_key returns it again.)
json
{
  "event": "optimize_batch.completed",
  "data": {
    "batch_id": "0d0f8c0e-6f0b-4a53-9a43-2f6d1f1f5b0a",
    "status": "completed",
    "idempotency_key": "q4-launch-step-1",
    "counts": { "total": 300, "done": 298, "failed": 2 },
    "created_at": "2026-10-14T09:00:00.000Z",
    "completed_at": "2026-10-14T09:21:40.000Z"
  },
  "timestamp": "2026-10-14T09:21:41.000Z"
}

Verify it. The X-Webhook-Signature header is the hex HMAC-SHA256 of the raw request body, with the signing secret as the key. There is no sha256= prefix. The X-Webhook-Event header is optimize_batch.completed. Compute the digest over the exact bytes you received, before you parse them, and compare in constant time:

python
import hashlib, hmac

def is_from_amdahl(raw_body: bytes, signature: str, signing_secret: str) -> bool:
    expected = hmac.new(signing_secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Delivery. Answer with any 2xx within 10 seconds. Anything else is tried again, up to 5 times in all, with waits of about 30 seconds, 2 minutes, 8 minutes and 32 minutes between tries. After that the delivery is marked failed, and the results are still on the status endpoint. Delivery is at least once, so treat a second POST with the same batch_id as the same event. A status read shows the delivery under webhook, as state: waiting until the batch completes, then pending, delivered or failed.

Batches over MCP

Two more tools carry batches. optimize_message_batch takes items and an optional idempotency_key and returns the same body as the REST submit. get_message_batch takes batch_id, and optionally wait_ms, only_finished, finished_since, limit, cursor and status, and returns the REST read's body. Over MCP a call with no wait_ms waits up to 25 seconds for the batch to complete (a call with cursor reads at once), so one call stands in for a polling loop. The body has two changes, so that a page fits in one tool result:

  • On a page of more than one item, a result's profile and brief are summaries: {summarized: true, fields, conflicts, unknowns, message_type} and {summarized: true, status, items, thin}, where each count is the length of that list. The item adds full_result, the arguments that read that one item whole and at once: {"batch_id": "...", "limit": 1, "wait_ms": 0, "cursor": "<index - 1>"}, with no cursor for index 0. A page of one item is never summarized.
  • A page larger than about 60,000 characters ends early. items_cut_for_size says how many items were left off, and next_cursor reads on from the first of them (with only_finished, they are left out of finished_cursor and counted in finished_remaining instead). The first item is always returned.

webhook_url is not available over MCP: poll get_message_batch instead.

From a chat agent

An agent in a chat (Claude, ChatGPT, Cursor) reaches the Optimizer through the MCP optimize_message tool. These rules keep a session useful. The tool's own description states them too.

  • Gather the context first. Before each call, collect what you know about the recipient: who they are, a reason to write now, what the sender offers, and the ask. Send it in context, and, where the workspace has the beta, the person in recipient: email, name and company, plus anything else you know about them in attributes. A draft is optional. When the user has one, send it in message with the context: the Optimizer keeps it as the base. A draft the agent writes itself is taken as the sender's own word too, so what it states can be stated in the message. Put what the agent knows in facts instead. See Context.
  • Up to ten messages in a chat. A campaign on the script. For more than 10 messages, use the campaign cookbook with an API key. It runs one message at a time, waits when it must, and can stop and resume. A chat agent that loops over 60 recipients is slow and hits the limits below. The agent tells you this before it starts.
  • One message at a time, never more than 3 at once. The limits count every user and key of the workspace together.
  • A whole list from the agent is a batch, never a loop. If you do want the agent to run the whole list, it queues it with the optimize_message_batch tool and reads it with the get_message_batch tool. A batch is not held to those limits. Its finish time is an estimate.
  • rate_limited: wait, then send the same request. The wait is error.details.retry_after_seconds. When error.details.reason is optimizer_unavailable, the Optimizer is down: stop the batch, tell the user, and send one request after the wait.
  • optimizer_error or optimizer_timeout: stop the batch. The other drafts would fail the same way. Tell the user. Try one request again after retry_after_seconds when the response carries it, or after at least one minute. Do not send the next one and do not retry in a loop.
  • Say the close in a rule. By default the message can close with a question the reader can answer in one line. When every message must close the same way (for example, it offers to send detail and never asks for a call), say it in one of the rules inside context. See Constraints.
  • A thin message means thin context. When the message is generic, add facts and run it again. The same context with the same options returns the same result, and it can count toward the monthly cap again. Do not invent a detail to get a different result.
  • Email and LinkedIn only. A call script or other copy is not a channel. Do not ask for one as an email.
  • With no connected data it is a style pass. The Optimizer needs no data, and then it checks no claim against customer conversations. A grade on such a workspace still runs: its four evidence lines abstain and only cta_clarity is graded.
  • Show the result as returned, and have a person read it before it is sent.

Request

json
{
  "channel": "email",
  "context": {
    "facts": [
      "Jordan Lee is Head of Platform at Corvid Health.",
      "Corvid shipped SSO last month, per their changelog.",
      "Met Jordan at the GTM dinner on Sep 24; he said his SDRs rewrite every AI draft.",
      "We automate user provisioning after SSO. Helix, a 300-person health tech company, cut provisioning tickets by 70%."
    ],
    "rules": ["Ask for a 15-minute call next week.", "Never mention pricing."],
    "max_words": 80,
    "banned_phrases": ["just", "reaching out"],
    "voice_examples": [
      "Hey Priya - congrats on the Series B. Are you still running onboarding by hand? Happy to share what worked for a team your size."
    ]
  }
}
FieldRequiredNotes
contextyes, in practiceWhat the message is written from: an object holding any of facts, rules, voice_examples and the constraints, as below. Any other key is refused. The message is only as specific as what you put here. See Context.
recipient, recipient_notes, message_typenoBeta: who the message is for. With recipient, Amdahl adds what it finds in your own data. A field with the wrong shape is refused with 400 invalid_input. See Recipient fields.
content_specnoBeta, per workspace: what is being written (stage, sequence step, objective and more). Ignored, never refused, unless your workspace has quality v2 turned on: the response names it in ignored_params. See Message type.
channelnoemail or linkedin. It sets the length and format the message is written and judged for. Send it: with no draft to read, there is nothing to infer it from.
evidencenoworkspace or off, default off. workspace also grades the message against your workspace's synced customer conversations (the four dimensions above, and slower); off is a fast style pass. Any other value is refused.
on_unsupportednoWhat to do with a claim in the message that nothing in your conversations backs and no fact states, so it acts only with evidence: "workspace": flag (the default) keeps it and lists it in unsupported_claims, remove lets the writer soften or cut it. See Claims nothing backs.
on_missing_inputnoWhat to do when your input cannot back everything the message should say. best_effort (the default) writes the best message your input supports, leaves out what it cannot back and lists it in omitted. ask writes nothing it would have to guess at and returns outcome: "needs_input" with the questions in ask. See When the input cannot back everything.
include_triesnotrue to also get tries: every version the run scored or checked, so you can show how it moved toward the one that came back. Default false, which leaves the response exactly as below without it. See Reading tries.
modenoimprove (the default) writes or rewrites the message, as this page describes. grade grades the draft in message and writes nothing: the result has no message, only grade and disclosures. A grade needs message, and is refused with 400 invalid_input when it also sends include_tries: true or on_missing_input: "ask". See Grade a message.
messagenoA draft, up to 10,000 characters. Sent with facts or rules in context, or with a recipient, it is the starting point of the message. It is the sender's own word: what it states may be stated, and what it states is never listed in omitted or asked about in ask. The message keeps its substance, order, layout and voice, adds what your facts give and follows your rules. A rule that needs a fact neither the draft nor a fact gives is listed in omitted, or asked about with on_missing_input: "ask". A draft sent with nothing else to work from (no facts, no rules and no recipient) is improved instead, and the response carries a draft_improved_no_context note with the developer audience.

A request with no message and no usable context or recipient has nothing to write from. It is refused with 400 invalid_input, and details.errors names the context to send.

Inside context:

KeyNotes
factsWhat the message is written from: who the recipient is, why you write now, what you offer and the proof you may cite, one fact per entry. Graded as caller-supplied evidence, never as a customer quote: see Facts. Up to 10, each up to 1,000 characters.
rulesHard constraints the message must follow, like "never mention pricing" or "ask for a 15-minute call". They are instructions, not copy: never quoted in the message. A version that breaks one gets one more try, told which rule it broke, and is never returned while it still breaks one. Up to 20, each up to 500 characters.
voice_examplesMessages the sender wrote themselves. The message matches their voice and does not copy their words. Up to 5, each up to 4,000 characters.
keep_ctaKeeps the draft's closing ask word for word, also when the draft is sent with facts or rules. With no draft, there is no ask to keep: say the ask in rules instead. See Constraints.
max_wordsThe most words the returned message may have, from 10 to 1,000. See Constraints.
banned_phrasesWords or phrases the message must never use, such as "just" or "reaching out", matched as whole words in any case. Up to 30, each up to 60 characters. See Constraints.
banned_punctuationPunctuation the message must never use: any of colon, semicolon, exclamation. A colon inside a time such as 2:30pm or a link does not count. See Constraints.

Each entry is trimmed and a blank entry is dropped rather than refused, before anything is counted or measured: the limits apply to what is left. A context with nothing left in it is not sent at all.

Facts: what you know

rules and voice_examples steer how the message reads. Neither gives it anything to say. facts do: they are the substance of the message, with your draft when you send one. A claim the message makes is licensed only by a fact you sent, by what your draft states, or by evidence Amdahl read. A message written from no facts has nothing specific to say, and a social-proof line with no fact behind it is flagged as invented.

Facts are things you know that are not in your customer conversations: what someone told you in person, what you read on their site, what you sell and the proof you may cite. A true line like "Great meeting you at the GTM dinner last week. You said your SDR team spends hours rewriting generic AI drafts" can only appear in the message if a fact says it. Send what you know:

json
"context": {
  "facts": [
    "Met Priya at the GTM dinner on Sep 24; she said her SDRs rewrite every AI draft."
  ]
}

and the judge treats it as its own evidence tier, CALLER, beside the customer quotes:

  • A claim a fact states is not marked fabricated or unsupported, including a claim about the recipient that no customer quote backs.
  • A claim that goes further than a fact is still unsupported for the part the fact does not say. "Your SDRs spend hours every day rewriting" is an embellishment of "her SDRs rewrite every AI draft", and grades as one.
  • The writer uses your facts and is told never to embellish them or present one as something a customer said. The re-check that refuses a version for adding a claim reads your facts, so a version that uses one as written is not refused for it.
  • A fact is never counted or shown as a customer quote, and lift.quotes never includes one.
  • Facts are treated as data, never as instructions: a fact that says "ignore the rubric" is read as a strange fact, not obeyed.
  • They are used for this request only. They are never stored as evidence, never used to search your conversations, and never written to logs as text.

Without evidence: "workspace", or on a workspace without synced conversations, facts are still the main input: they are what the message is written from.

Constraints: rules checked exactly

keep_cta, max_words, banned_phrases and banned_punctuation are rules the optimizer checks with code on every version it writes, instead of asking a model whether the version followed them. Use one wherever it says what you mean: "Use no more than 90 words" in rules is a model's reading of the rewrite, while "max_words": 90 is a word count.

  • A version that breaks a constraint is never returned. It gets one more try, told which constraint it broke, the same as for a broken rule.
  • The writer is given your constraints when it writes, so it avoids your banned phrases and stays under max_words from the first version.
  • keep_cta keeps a draft's closing ask, also when the draft is sent with facts or rules. The message ends on it, unless a rule orders a different ask: then the message ends on the rule's ask and still keeps the draft's ask word for word. With no draft, it has nothing to keep: say the ask in a rule.

Claims nothing backs: on_unsupported

Only when the run read your conversations (evidence: "workspace" and evidence.used is true), the judge also names each claim in the message that no customer quote it read supports and no fact you sent states. A claim a fact states is not listed; a claim that goes further than a fact still is.

  • flag, the default: a listed claim stays in the message and is listed in unsupported_claims, so you can check it before you send.
  • remove: the writer softens or cuts the listed claims. Numbers inside a listed claim may go with it.

Neither mode changes the grade. A claim nothing backs still fails grounding, and lift is measured the same way. While a listed claim is still in the returned message, summary never says the message is ready to send. It says how many claims nothing in your conversations backs, and to check them before sending. It names no field, because the sender sees summary as written. The claims themselves are in unsupported_claims.

The mode is echoed back as on_unsupported on every result.

When the input cannot back everything: on_missing_input

A rule can ask for something your facts do not give, for example "Mention our customer count" with no fact that states it. The Optimizer never makes the missing part up. on_missing_input decides what it does instead:

  • best_effort, the default: the Optimizer writes the best message your input supports and leaves out each part it cannot back. Each part it left out is listed in omitted, with a plain reason. You always get a message.
  • ask: before it writes, the Optimizer checks whether your input can support the request as asked. When it cannot, it writes nothing and returns outcome: "needs_input" with the questions in ask, in seconds. When the check passes, the run is the same as best_effort.

In both modes, every message that comes back was checked: every sentence against your facts, your rules and the evidence Amdahl read, and the whole message against your constraints. A sentence that nothing backs is removed, so nothing in it is invented, and there is no unchecked fallback. When the message still does not fully follow a rule, or misses a limit such as max_words, that is listed in omitted too, with why. Use ask when a person can answer questions before the message is sent. Use best_effort (or send nothing) when the call runs without a person, for example in a batch, where ask is refused.

The mode is echoed back as on_missing_input on every result. Any value other than best_effort or ask is refused with 400 invalid_input.

Message type (beta, per workspace)

content_spec says what is being written. It is in beta and turned on per workspace (quality v2). On a workspace without it, content_spec is ignored, not refused: the message is written as though you had not sent it, and ignored_params names it. A content_spec with the wrong shape (an unknown key, a value that is not in its list) is refused with 400 invalid_input on every workspace. A batch item does not take content_spec.

json
{
  "message": "Hi Sam, worth a 15 minute chat next week?",
  "channel": "email",
  "content_spec": {
    "stage": "cold",
    "sequence": { "step": 3, "prior_touches": 2, "days_since_last_touch": 4 },
    "objective": "meeting"
  }
}
FieldNotes
channelemail or linkedin.
formatmessage, connection_note, inmail, post or recap. When not sent: message.
stagecold, engaged, active_deal, customer or closed_lost.
sequencestep (1 to 100), prior_touches and days_since_last_touch, each a whole number or null.
objectivereply, meeting, intro, re_engage, next_step or awareness. When not sent: the usual objective for the stage.
audiencepersona, industry and firmographic_segment, for a message to a segment with no single recipient.
campaign_idYour own id for the campaign, up to 200 characters.
planned_send_atWhen you plan to send the message, in ISO-8601 UTC (2026-10-23T18:00:00Z).

On a workspace with quality v2, every call returns message_type: the type of message the call is, whether or not you sent content_spec. Its key is channel.format.stage.step_band, for example email.message.cold.1 or email.message.cold.2plus. step_band is 2plus when sequence.step is 2 or more or sequence.prior_touches is 1 or more, else 1. Each field comes from the first of these that gives it: your content_spec, then your message_type and channel, then the recipient's relationship with your workspace (with recipient), then a default. message_type.derived names where each field came from (caller, legacy_message_type, crm, sequence, channel_default, stage_default, default or none). The writer does not use message_type yet: the call sends the Optimizer the same request as without content_spec. Because message_type is read here, a workspace with quality v2 does not name it in ignored_params.

Recipient fields (beta)

recipient, recipient_notes and message_type say who the message is for. They are in beta on every workspace. A field with the wrong shape (an unknown key in recipient, notes over the limit) is refused with 400 invalid_input on every workspace. With recipient, Amdahl reads your own data for that recipient and adds what it finds to the message's context. recipient_received and evidence_run_id confirm what the call used. recipient by itself counts as context, so a call with no message is not refused as having nothing to write from. Ask Amdahl to turn on intake use if you want recipient_notes and recipient.attributes to change the profile.

json
{
  "message": "Hi Dana - saw Northwind opened a Denver office. Worth a short call?",
  "recipient": {
    "email": "[email protected]",
    "name": "Dana Whitfield",
    "company": "Northwind Labs",
    "domain": "northwind.example",
    "linkedin_url": "https://www.linkedin.com/in/dana-example",
    "attributes": { "Job Title": "VP Sales", "Employee Count": 140, "Hiring SDRs": true }
  },
  "recipient_notes": "Series B. Said on the webinar they are hiring six SDRs this quarter.",
  "message_type": "cold"
}
FieldNotes
recipientAny of email (up to 320 characters), name and company (up to 200 each), domain (up to 253), linkedin_url (up to 300) and attributes. Any other key is refused. Send email, name and company when you know them: Amdahl finds the person in your CRM and calls by email, else by name and company, and reads the role from the title your CRM or calls hold. attributes.title sets the role only where the workspace has live intake on. linkedin_url is kept as a fact but finds no one.
recipient.attributesA flat map of text, number, true/false or null values: a whole Clay or CSV row fits. Up to 100 keys, each key up to 100 characters, each text value up to 1,000 characters. A key that names a business, user, tenant or workspace id (business_id, user_id, tenant_id, workspace_id, in any case or spelling) is refused: the credential decides who is sending and for which workspace.
recipient_notesFree text about the recipient, such as your own research or a pasted CRM note. Up to 10,000 characters.
message_typecold, re-engage, follow-up or deal. Use follow-up for a reply in a thread that is in progress.

With recipient, the optimizer also receives what Amdahl found: the profile fields it read from your records, the brief with its quotes, and your CRM's notes on the account. The grade and the rewrite can use them, each only as far as it goes. See What the optimizer receives. The response confirms what arrived, gives the run an id of its own, and says who the recipient is:

  • recipient_received: { "fields", "attribute_count", "notes_chars", "message_type" }. fields lists the fields received, such as recipient.email and recipient_notes. Names and counts only, never a value you sent.
  • evidence_run_id: a new id for this run. run_id keeps its meaning, the optimizer's own run.
  • profile: who the recipient is, read from your CRM, your calls and company data. Only when you sent recipient; recipient_notes or message_type alone give no profile. See The recipient profile.
  • brief: what buyers like the recipient said in your own calls. Only with profile. See The brief.
  • intake: what Amdahl read from your recipient_notes and recipient.attributes, and from your CRM's notes on the recipient's account: shown and not used, except on a workspace with intake use turned on. Only when there was such text. See Intake (beta).
  • cited_evidence: the quotes from the brief that the optimizer's grader cited on the message that came back. Only when the optimizer received something from the profile or the brief. See What the optimizer receives.
  • thesis: the shape of a cold message, built from the brief and the facts Amdahl holds. Only when the profile's message_type is cold. See The thesis.
  • fit_for_audience: whether the message that came back addresses a pain or an objection of buyers like the recipient, or the account's own history, from the brief. Report only. Only when the brief holds such a point. See Fit for the audience.

The first two keys appear only when you sent at least one of the three fields with something in it. An empty recipient ({}, or only blank strings) and blank recipient_notes count as not sent: the call and its response are the same as without them. A refusal names the field and the reason, never the value you sent. An attribute is named by its position in the map, counted from 0 in the order you sent it, never by its key (for example $.recipient.attributes[3]: must be at most 1000 characters), because a key can hold personal data. An unknown key inside recipient is named only when it reads as a field name, such as phone; any other unknown key is only counted.

There is no field for who is sending, in context or anywhere else. The sender is derived from the credential you called with and cannot be supplied on the wire; see Whose voice it learns.

A message over 10,000 characters, a channel other than email or linkedin, an evidence other than workspace or off, an on_unsupported other than flag or remove, or an on_missing_input other than best_effort or ask is refused with 400 invalid_input. So is a context with a key other than rules, voice_examples, facts, keep_cta, max_words, banned_phrases and banned_punctuation, more entries than its limit, an entry over its character limit, a max_words outside 10 to 1,000 or a banned_punctuation mark not in the list, with the field named in details.errors (for example $.context.rules[3]: must be at most 500 characters); nothing is sent to the optimizer. An empty message, or one of only whitespace, counts as not sent.

The recipient profile (beta)

Amdahl looks the recipient up in this order and stops at the first source that answers each field. A later source only fills a blank:

  1. what you sent in recipient;
  2. your CRM: the contact by email, or, with no email, by name and company together, then that contact's company;
  3. your calls: the person by email, when they spoke on a call;
  4. the company: the CRM company of the contact it found, else by domain (or by the email's domain, unless it is a free mailbox such as gmail.com), else by company, then the company data Amdahl holds from People Data Labs for that domain. A company matches only a CRM company with the same whole name: case, accents, commas, periods and a legal suffix such as "Inc." or "LLC" do not count, so "Acme" finds "Acme, Inc." but never "Acme Widgets";
  5. your history with that company: its open deal, its last conversation, and whether your workspace has won any deal.

It calls no outside service and no model. It reads only what your credential may see: a member whose data access is limited to some companies gets nothing from the others.

json
"profile": {
  "fields": [
    { "id": "ev_4f1c...", "kind": "fact", "tier": "CRM", "layer": "contact", "field": "title",
      "value": "VP Engineering", "source_tag": "crm", "source_ref": "hubspot:contact:501",
      "snippet": "", "observed_at": "2026-10-13T16:00:00.000Z", "published_at": "2026-10-01T10:00:00.000Z",
      "confidence": "high", "expires_at": "2026-11-12T16:00:00.000Z",
      "producer": "recipient.resolver", "producer_version": "1" }
  ],
  "conflicts": [],
  "unknowns": [{ "code": "person_not_found" }],
  "cohort": { "role_level": "executive", "industry_normalized": "Software & Internet",
              "firmographic_segment": "mid_market", "evidence_ids": ["ev_4f1c...", "ev_9a20...", "ev_c3d1..."] },
  "rungs_tried": [{ "rung": "crm_contact", "step": "6.2.1", "input_kind": "email", "outcome": "answered", "fields": ["title"] }],
  "relationship_state": "active_deal",
  "message_type": "deal"
}
KeyNotes
fieldsOne evidence record per field: email, name, linkedin_url, title, department and role_level (layer contact); company, domain, industry, industry_normalized, firmographic_segment, employee_count, country, last_touch_at and quotable_utterances (layer account); deal_name and deal_stage of the company's newest open deal (layer deal). Every record has an id and a source_tag that says where the value came from: caller (you sent it), crm (your CRM), inferred:calls (your calls), enrichment:pdl_bulk or enrichment:pdl (People Data Labs) or inferred (worked out from another field, such as the role level from the title). expires_at follows the kind of fact: 180 days for who the person is, 30 for title and employer, 90 for company facts. quotable_utterances measures an email on the words its sender wrote, without the thread it replied to, and reads at most the 20 newest emails of a company: for a company with more, the count is a lower bound, and it is left out when that lower bound is zero.
conflictsValues that disagree with the field's value, or with each other, each with its record. Amdahl never merges them. When two sources of the same standing disagree (two CRM records for one email), the field is left out and both are listed.
unknownsWhat Amdahl could not answer, by name: no_identifier (nothing to look up), person_not_found, company_not_found, ambiguous (two people or two companies match, so none was picked), public_off (only a web lookup could answer, and web lookups are not on), no_account_history (the company has nothing it said that a quote could come from, or is not in your CRM) and no_won_deals (your workspace has no won deal yet, and no deal whose outcome your CRM does not record). A lookup that failed is never reported as "not found".
cohortThe buyers like this recipient: role level, industry and company size, each only from a high-confidence field, with the ids of the records it used. An axis Amdahl could not read is null.
rungs_triedEach lookup that ran or was skipped, with how it ended (answered, no_match, ambiguous, error, skipped) and the names of the fields it returned. Each lookup has 8 seconds, and all lookups share one time limit of 20 seconds. A lookup that the shared limit did not reach is skipped with the reason deadline, and a lookup that was still running is an error with the reason timeout.
relationship_stateWhere your workspace stands with the company, from your CRM's own deal outcomes: active_deal (an open deal), customer (a won deal), closed_lost (a lost deal) or new. Absent when it could not be read: for example when two companies match, when there is no company to look up (a LinkedIn URL alone, or a free mailbox with no company), or when the company has a deal whose outcome your CRM does not record and no open deal.
message_typeThe kind of message: the message_type you sent, or else the one that follows from relationship_state (new is cold, active_deal is deal, customer and closed_lost are re-engage). follow-up comes only from you.
override_byPresent only when you sent message_type: the user id of the credential that sent it. relationship_state keeps the value Amdahl read.

The brief (beta)

The brief says what to say to the recipient, from your own calls and CRM: what the recipient's own company said, what buyers like the recipient said, the objections those buyers raised, and the proof points your team uses. Code builds it from the profile, and it reads only what your credential may see. No outside service is involved. One model call (Claude Haiku) writes a short label for each pain, objection and proof; it can only name what the item's own evidence says, and it adds no item, count or fact.

Amdahl looks for the recipient's group in a fixed order and stops at the first that has enough evidence. For a recipient with a role level:

  1. the same role level, industry and company size;
  2. the same role level and industry;
  3. the same role level;
  4. all your customers.

For a recipient with no role level (for example one with only a company), the industry can still pick the group:

  • step 5: the same industry and company size;
  • step 6: the same industry;
  • step 4: all your customers.

A step needs every value it names: a recipient with neither a role level nor an industry (for example one with no identifier) goes straight to step 4. Within a step, the customer side of your calls is grouped by theme. A theme counts only when it has at least 3 speakers, 25 utterances and 2 companies. They are applied to each theme rather than to the group as a whole. A step where no theme reaches them is too thin, and Amdahl moves to the next one. Deal stage never decides the group.

The brief has four kinds of item, listed in this order:

  • history: what the recipient's own company said (tier ACCOUNT), only when the profile matched one company in your CRM. Up to five of the company's customer-side quotes, the ones that share the most words with your draft first, then the newest. An email counts on the words its sender wrote, without the thread it replied to.
  • pain: up to five themes of the group above (tier SEGMENT, or CORPUS at step 4), each with up to two quotes from different companies.
  • objection: up to three objections the same group raised on calls, by how often they came up and then how recently. Each has the newest objection as the buyer phrased it, what your rep answered (null when the call does not show an answer), how it went (resolved, deferred, hardened or unresolved) and a count by outcome.
  • proof: up to three positioning claims mined from your own calls (tier CORPUS), ranked by how each claim did against deal outcomes in your whole workspace (its lift, not this group's). First come the claims whose lift tier is high, medium or low and whose lift is above 1, by tier, then by how many companies the claim reached, then the most recently found. Each of these carries lift_tier, tier_basis (lagging: measured on win rates; leading: on how buyers received it) and headline_lift. Then come the claims with no lift yet, by how many companies heard them, then the most recently found, with lift_tier, tier_basis and headline_lift set to null. A tier says how sure the measurement is, not which way it points, so a claim with a lift of 1 or less and a claim whose lift could not be measured (abstain) are listed under thin with their numbers. A claim your team has not confirmed yet is listed under thin as proposed. Claims written by hand as hypotheses are never used. An item with a tier names a customer (disclosure: "named", with named_company and named_domain) only when that customer won a deal and heard the claim; it names the most recently won one, read from your CRM, and its evidence_ids holds that customer's CRM record. Every other item is "anonymized". Proof is not ranked when your workspace has no won deal yet (no_won_deals), when the call has an X-Amdahl-As-Of header, or when no claim has a lift yet (no_lift): active claims are then listed by how many companies heard them, then the most recently found, with no tier, no lift and no name.

The recipient's company's CRM notes (HubSpot, Attio and Pipedrive) are facts, not quotes: up to the 20 newest, inside about 4,000 tokens, each tagged crm:<vendor> with its date. They are listed in account.note_ids.

Each pain, objection and proof item gets a label of a few words and label_cites, the ids the label rests on. A label may cite only the item's own id and its evidence_ids, and Amdahl drops any other id. A label left with no citation is dropped too, so its item keeps label: null. The history item is never labelled. When the model call fails or times out, every item keeps label: null and labels says why; the rest of the brief is unchanged. Amdahl then keeps that group part for 10 minutes only (cache.reason: "labels_failed", ttl_seconds: 600): a call for that group in those 10 minutes gets the same part with no new model call, and the first call after them reads the group again and makes a new label call. The model call is made only for a group part that Amdahl will keep (see below), so a call with an X-Amdahl-As-Of header, a call while the cache is off, and a call where a read did not answer make no model call (labels.status: "skipped"). The brief's reads have one 15-second limit, after the profile's own 20 seconds. The label call comes after the reads and has 6 seconds of its own, and only a call that built the group part makes it. So a call with recipient can take up to about 41 seconds longer before the optimizer starts.

Amdahl keeps the group's part of the brief (its ladder, pains and objections), proof and their labels for 24 hours, for each workspace, data access and group. The group is the values the ladder can use: a recipient with neither a role level nor an industry shares the all-customers part with every other such recipient, whatever their size, and a recipient with no role level never shares a part with one that has a role level. A second brief for a buyer in the same group within that time reads none of them again and makes no model call; its built_at is the time the kept copy was built. The recipient's own company's quotes and notes are read on every call and never kept. New calls do not refresh a kept copy: it is replaced only when its 24 hours are up (cache.invalidation: "ttl_only"). With an X-Amdahl-As-Of header the cache is not used.

json
"brief": {
  "version": 2, "status": "ok", "built_at": "2026-10-14T16:00:00.000Z",
  "cohort_used": { "step": 1, "tier": "SEGMENT", "role_level": "executive",
    "industry_normalized": "Software & Internet", "firmographic_segment": "mid_market",
    "counts": { "quotes": 45, "companies": 3, "speakers": 3 },
    "provenance": "step 1 of 4 (SEGMENT): role_level=executive, industry_normalized=Software & Internet, firmographic_segment=mid_market" },
  "widening_steps": [{ "step": 1, "tier": "SEGMENT", "cohort": { "...": "..." }, "outcome": "used",
    "counts": { "quotes": 45, "companies": 3, "speakers": 3 }, "themes_above_floor": 1 }],
  "items": [{ "id": "ev_5f03...", "kind": "pain", "tier": "SEGMENT",
    "label": "Broken dashboards reach leadership first", "label_cites": ["ev_5f03...", "ev_8f76..."],
    "theme": "Data pipeline firefighting", "counts": { "quotes": 27, "companies": 3, "speakers": 3 },
    "last_spoken_at": "2026-09-30T10:00:00.000Z", "evidence_ids": ["ev_8f76...", "ev_1c2d..."] },
    { "id": "ev_77aa...", "kind": "objection", "tier": "SEGMENT",
      "label": "Price above this year's tooling budget", "label_cites": ["ev_77aa..."], "theme": "pricing",
      "counts": { "objections": 3, "calls": 3, "companies": 2 },
      "example": { "objection_text": "The estimate is above this year's tooling budget.",
        "rep_response": "We can start with the twenty tables that matter most.",
        "objection_outcome": "resolved", "evidence_utterance_ids": ["..."] },
      "outcomes": { "resolved": 1, "deferred": 1, "hardened": 0, "unresolved": 1 },
      "last_raised_at": "2026-09-02T16:00:00.000Z", "evidence_ids": [] },
    { "id": "ev_31c0...", "kind": "proof", "tier": "CORPUS", "label": null, "label_cites": [],
      "claim": "Teams cut weekly data firefighting from two days to two hours after rollout.",
      "category": "proof_point", "frequency": 3, "lift_tier": "high", "tier_basis": "leading",
      "headline_lift": 1.86, "delivered_companies": 3, "disclosure": "named",
      "named_company": "Northwind Data", "named_domain": "northwind.example.com",
      "evidence_ids": ["ev_5d2e..."] }],
  "thin": [{ "kind": "pain", "tier": "SEGMENT", "theme": "Rollout planning and pricing",
    "counts": { "quotes": 18, "companies": 1, "speakers": 1 }, "reason": "below_floor",
    "short_by": { "quotes": 7, "companies": 1, "speakers": 2 } },
    { "kind": "proof", "tier": "CORPUS", "claim": "Pricing scales with tables monitored, not seats.",
      "frequency": null, "reason": "proposed", "lift_tier": null, "tier_basis": null,
      "headline_lift": null, "delivered_companies": 0 }],
  "account": { "status": "no_account", "note_ids": [] },
  "proof": { "ranked": true, "reason": "lift_tier" },
  "thin_omitted": 0,
  "triggers": [],
  "records": [{ "id": "ev_8f76...", "kind": "quote", "tier": "SEGMENT", "field": "quote",
    "value": "Our dashboards break and leadership finds out first.", "source_tag": "inferred:calls",
    "source_ref": "calls:...", "snippet": "Our dashboards break and leadership finds out first.",
    "observed_at": "...", "published_at": "...", "confidence": "high", "expires_at": "...",
    "producer": "recipient.brief", "producer_version": "1" }],
  "rules": { "floors": { "applied": "per_theme", "distinct_speakers": 3, "utterances": 25, "distinct_companies": 2 },
    "caps": { "pain": 5, "objection": 3, "proof": 3 }, "rank": ["count", "recency"] },
  "labels": { "status": "partial" },
  "cache": { "status": "miss", "stored": true, "ttl_seconds": 86400, "invalidation": "ttl_only" },
  "notes": []
}
KeyNotes
statusok, or unavailable when the read of the group's calls did not answer (reason: for example deadline, scope_disjoint when your data access shows you no calls, invalid_as_of, or no_generation_at_as_of when an X-Amdahl-As-Of time is earlier than every saved set of themes for your workspace, so the themes of that time are not known). An unavailable brief claims no group, pain or objection: it is never reported as a small group. The company's own part and proof keep what their own reads found.
cohort_usedThe group the brief used: its step, its values (a value the step does not use is null), its counts, and provenance, one line that names the step and the steps it widened from. tier is SEGMENT for steps 1, 2, 3, 5 and 6 ("teams like yours") and CORPUS for step 4 (customers in general).
widening_stepsEvery step tried, in order, ending with the step used: used, thin (measured, no theme reached the minimums) or skipped (the profile lacks a value the step needs; reason names it, such as no_industry_normalized). Each measured step has its utterances (quotes), companies and speakers.
built_atWhen the group's part, proof and their labels were built. On a cache hit it is the time the kept copy was built, not the time of this call.
itemsThe items above. Every item has an id, a kind, a tier, a label with its label_cites (null and [] when it has none, and always for history) and evidence_ids, the quotes behind it (none for an objection; for a proof, only the CRM record of the customer a named item names). A pain has its counts and its theme name (null when it could not be read, see notes).
thinItems left out, with the numbers: a pain below_floor with short_by (how far each count is under its minimum), a pain, objection or proof over_cap with its rank past the limit, or a proof that is proposed, lift_not_above_1 or abstain (see proof). At most ten pains are listed; thin_omitted counts the rest.
accountstatus is read when the profile matched one company in your CRM and at least one of its two reads (its quotes, its CRM notes) answered, unavailable when neither answered (see notes), else no_account. note_ids lists the CRM note facts in records, newest first.
proofHow proof is listed. ranked: true with reason: "lift_tier": ranked by lift tier as above. ranked: false lists active claims by how many companies heard them, then the most recently found, with no tier, no lift and no name; reason says why: no_won_deals (your workspace has no won deal yet, the profile's named unknown), as_of_cut (an X-Amdahl-As-Of header is set, and the lift reads outcomes after that time), no_lift (no claim of your workspace has a lift yet) or proof_unavailable (the read did not answer; see notes). A proof thin entry's reason is over_cap (with its rank), lift_not_above_1 (a tier, and a lift of 1 or less), abstain (the lift could not be measured; lift_tier is abstain, or a tier with headline_lift: null) or proposed; when proof is ranked it carries lift_tier, tier_basis, headline_lift and delivered_companies.
recordsOne evidence record for each item, each quote and each CRM note. Items point at records by id. Only a kind: "quote" record holds a buyer's exact words, in snippet. Every other record is kind: "fact": an item's own record (a pain, the account's history, an objection, a proof claim) with an empty snippet, a CRM note (tier CRM, source tag crm:<vendor>, published_at its date) whose snippet is its text, or the won customer a named proof item names (tier CRM, field won_customer, source tag crm, source_ref <crm>:company:<id>). Records about the matched account (its history item, its quotes and its notes) carry layer: "account"; group and workspace records carry no layer. Records expire 24 hours after observed_at.
notesHow the brief was built, by code: themes_unnamed (theme names could not be read, for example for a member whose data access is limited), quotes_unavailable (the counts stand, the quotes could not be read), as_of_cut (an X-Amdahl-As-Of header limited the brief's own reads of your calls, notes and objections to those up to that time, and its themes and their names are the ones saved for your workspace at that time; the profile and the optimizer's own read of your calls are not cut), axis_not_in_vocabulary (a profile value Amdahl does not use for groups), themes_not_customer_voice (themes of email boilerplate, your own templates or internal notes, left out before the themes are ranked, with a count), theme_kinds_unread (your data access does not show which themes are customer voice, so none was left out and such a theme can be an item), account_quotes_unavailable, crm_notes_unavailable, objections_unavailable and proof_unavailable (that read did not answer; reason says why, such as scope_disjoint for proof when your data access is limited: then no proof is shown and no customer is named), proof_names_unavailable (the read of the won customers did not answer, so every proof item is anonymized), proof_names_unresolved (a count of ranked items whose won customer your CRM does not give as exactly one company with a name or a domain; each is anonymized), crm_notes_over_budget (older notes left out, with a count) and proof_tier_left_out_as_of (with an as-of header, proof shows no tier and lists only claims found by then, each with its current text and status).
labelsWhat the label call did: labelled (every pain, objection and proof the brief shows), partial (some), unlabelled (none; reason is model_unconfigured, timeout, model_error, no_tool_call or no_cited_label), skipped (no model call was made; reason is cache_bypassed for an as-of header, cache_off or partial_read) or not_run (nothing to label).
cachestatus is hit (from a kept copy), miss (built on this call; stored says whether it was kept, and reason why not: partial_read when a read did not answer, entry_exists when another call kept one first, store_failed; a part kept after its label call timed out or failed has stored: true and reason labels_failed), bypassed (an as-of header) or off (no cache; reason). ttl_seconds is 86400, or 600 for a part whose label call timed out or failed, and invalidation is always ttl_only.
triggersAlways empty in this release.

The thesis (beta)

When the profile's message_type is cold (the one that follows from relationship_state: new, or the one you sent), the response also carries thesis: the shape of a cold message, its slots picked by code from the brief and from facts Amdahl holds. When it has a signal and a problem, one model call (Claude Haiku) writes the mechanism: one sentence that says why the problem matters to the recipient now, citing the ids it rests on. Amdahl keeps the sentence only when it cites at least one signal and every id it cites is evidence of this run; otherwise it asks once more, and then the thesis is no_thesis. The call has 2 seconds, its retry included, and the optimizer call waits for it, so a cold message can take up to 2 seconds longer. An ok thesis is sent to the optimizer's writer, which uses it to shape the rewrite (see What the optimizer receives); the grader never sees it, so the thesis can change the rewrite but never how a message is graded. The sentence is written again on each call, so two identical calls for a cold message can send different theses: each is then its own optimizer run, counts as its own call and can return a different result. A no_thesis is not sent. Any other message type gives no thesis key.

json
"thesis": {
  "version": 1, "status": "ok", "empty_slots": [], "confidence": "high",
  "signal": [{ "id": "ev_2b91...", "text": "Evaluating a new data warehouse in Q1.", "tier": "CRM", "source_tag": "crm:hubspot" }],
  "problem": { "id": "ev_5f03...", "text": "Data pipeline firefighting", "tier": "SEGMENT", "source_tag": "inferred:calls" },
  "mechanism": { "text": "Moving to a new warehouse in Q1 puts their pipeline firefighting in plain view.",
    "cites": ["ev_2b91...", "ev_5f03..."] },
  "consequence": { "id": "ev_77aa...", "text": "pricing", "tier": "SEGMENT", "source_tag": "inferred:calls" },
  "new_way": { "id": "ev_31c0...", "text": "Teams cut weekly data firefighting from two days to two hours.", "tier": "CORPUS", "source_tag": "inferred:calls" },
  "proof": { "id": "ev_31c0...", "text": "Teams cut weekly data firefighting from two days to two hours.", "tier": "CORPUS",
    "source_tag": "inferred:calls", "lift_tier": "high", "disclosure": "anonymized" },
  "ask": { "text": "a short call", "source": "channel_default" },
  "records": [],
  "latency_ms": 840, "model_calls": 1
}
KeyNotes
signalFacts that are events or things someone told you: the recipient's company's CRM notes (newest first) and, on a workspace with intake use, the high facts intake read from your notes or from a CRM note it parsed (yours first). At most five. Who the person is (title, company, industry, size), the last conversation and anything read off your calls are never signals, and neither is a fact inside your own angle or pitch.
problemThe angle you gave in your notes ("my angle is ..."), on a workspace with intake use; else the brief's first pain of buyers like the recipient (steps 1, 2, 3, 5 or 6 of the ladder). A pain of all your customers (step 4) never fills it.
mechanismOne sentence that links a signal to the problem, with cites, the ids it rests on (at most 10): at least one is a signal, and every one is a record of this run. null when the thesis is no_thesis.
consequenceThe brief's first objection of buyers like the recipient (steps 1, 2, 3, 5 or 6), or null.
new_wayYour pitch ("the pitch is ..."), on a workspace with intake use; else the claim of proof.
proofThe brief's first proof claim (its top-ranked one when proof is ranked), with its lift_tier. disclosure is always anonymized: a thesis never names a customer, even when the brief's proof item does.
askYour ask ("my ask is ..."), on a workspace with intake use (source: "rep", with its record id; its text is cut to 300 characters, and the record keeps all of it); else the channel's default (source: "channel_default"): "a short call" for email and for a call with no channel, "a reply" for linkedin.
status, reasonok needs a signal, a problem and a mechanism that cites a signal. Otherwise no_thesis, and reason says why: brief_unavailable (the brief's read of the group's calls did not answer), notes_unavailable (no signal, and the read of the company's CRM notes did not answer, so Amdahl does not know whether there is one) or missing_slots (no signal or no problem), all without a model call; mechanism_uncited (the sentence failed the check twice), timeout (the 2 seconds passed, or too little of them was left to ask again), model_error (the call failed) or model_unconfigured (no model is set up for this deployment).
empty_slotsEvery slot that is empty, of signal, problem, mechanism, consequence, new_way and proof.
confidenceHow specific the problem is: high for your own angle or a pain at step 1, medium at step 2, low at step 3, 5 or 6, null with no problem.
recordsThe evidence records the thesis made for your hints and the intake facts it uses (field thesis_hint or fact, tagged caller:extracted or crm:extracted). Every slot id is a record here or in brief.records. Empty on a workspace without intake use.
latency_ms, model_callsHow long the mechanism step took and how many model calls it made: 0, 1, or 2 with its one retry. 0 and 0 when no call was made.

Each slot carries the tier of its record, which says what it licenses: a CRM or CALLER fact states only what it says, a SEGMENT item speaks for buyers like the recipient, and a CORPUS claim for your customers in general. The thesis is kept with the run's evidence. With intake use on, a hint with none of the phrases above is shown in intake.parsed and fills no slot.

What the optimizer receives (beta)

On a call that sends recipient, the optimizer receives, beside your own context:

  • the profile fields Amdahl read from your records (your CRM, your calls, company data), each with its source and record id. A value you sent yourself in recipient is not repeated, and neither are the recipient's email address, LinkedIn URL and quotable_utterances. The writer may use a field to address the recipient. A field licenses no claim: a figure the writer takes from it is graded as an added figure;
  • the brief's history, the pains and objections of buyers like the recipient (steps 1, 2, 3, 5 and 6 of the ladder), and proof, each with its label and with the ACCOUNT and SEGMENT quotes behind it: at most 10 quotes per item and 20 in all, taken one per item in turn. A step-4 pain or objection (what all your customers said) is not sent, because the optimizer reads your customers' calls itself when you send evidence: "workspace". Proof is sent with its claim and no quote, in the brief's order, and never with the customer it names: a named customer stays in the response. An item with no label is sent with its theme, its claim, or for history the company's name;
  • your CRM's notes on the account, up to 20, each as a fact from your CRM with its date. A fact licenses only what it states, and it is never a quote;
  • for a cold message whose thesis is ok: the thesis, last. Each slot goes with its record id, tier and text, the mechanism with the ids it cites, and the ask as text; proof keeps its disclosure and never names a customer. The writer uses it to shape the message, and the grader does not see it. The checks that throw out a version with an invented figure or claim accept what the signal, problem, consequence and proof texts and the ask state; the mechanism and the new way license nothing. A no_thesis, and every other message type, sends no thesis.

The grader may cite a brief quote the way it cites a quote of your calls. A quote of buyers like the recipient licenses "teams like yours", never a claim about the recipient. The response then carries cited_evidence, the brief quotes the grader cited on the message that came back:

json
"cited_evidence": [
  { "id": "ev_7d0b...", "tier": "SEGMENT" },
  { "id": "ev_31aa...", "tier": "ACCOUNT" }
]

Each id is a quote record in brief.records. [] means none was cited; null means the optimizer's answer could not be read. The key is absent when the optimizer received nothing from the profile or the brief. That happens only when there is no profile field read from your records, no brief item other than a step-4 pain or objection, and no CRM note. Proof is about your workspace, not the recipient, and a group of buyers can come from a title you sent. So on a workspace with proof claims, a call that sends recipient sends the brief's proof even when Amdahl found nothing about the recipient. cited_evidence is in beta.

Fit for the audience (beta)

When the brief the optimizer receives holds a point that this recipient can recognize, its grader answers one more question about the message that came back: does it address one of those points? A point is a pain or an objection of buyers like the recipient (steps 1, 2, 3, 5 and 6 of the ladder, tier SEGMENT), or the account's own history (tier ACCOUNT). Proof, and a step-4 pain or objection, never count. Naming the company does not address its history: the history is what the account's own quotes say. The grader reads no new data for this and makes no extra model call. The response then carries fit_for_audience:

json
"fit_for_audience": { "verdict": "pass", "cited_id": "ev_9c41...", "tier": "ACCOUNT" }
FieldNotes
verdictpass: the message addresses a point of the brief, no further than its tier permits (an ACCOUNT point can be said about the recipient's company, a SEGMENT point only about teams like theirs), and the grader named it. fail: it addresses none. null: not measured. The grader did not answer, or it passed the line without naming a point it was shown. A null is never a pass.
cited_idOn a pass only: the id of that point, an item of brief.items. null otherwise.
tierOn a pass only: that item's tier, ACCOUNT or SEGMENT, as the brief shows it. null otherwise.

Report only, until it is calibrated. The line never decides whether the optimizer tries a rewrite or asks you questions instead (needs_input). It is not part of lift or of the rule that stops the rewrites early. The independent re-check that can refuse a rewrite (veto_reason: not_better) does not read it. But it counts in the optimizer's own score, and that score picks which scored version comes back. Your draft is one of those versions. So the line can change which version you get, and also whether outcome is rewritten or kept. With such a brief, a try's score includes it, and its aimed_at can name fit_for_audience. Do not read a pass as "ready to send".

The key is absent when the optimizer received no such point: you sent no recipient, or the brief holds only proof and step-4 pains or objections. It is also absent when the optimizer did not grade the line. null in place of the object means the optimizer's answer could not be read. fit_for_audience is in beta.

Context pack use (beta)

On some workspaces, a call also carries a context pack: what Amdahl already knows about the recipient's company, split into what Amdahl's own records show and what the company or the market has published. When a call carries one and the optimizer graded against it, the response carries context_use, the grader's verdict on whether the message that came back is positioned on something from the pack:

json
"context_use": {
  "pack_id": "cp_7f3a...",
  "identity": "high",
  "rows": [
    { "row": "relevant_positioning_with_internal_context", "bucket": "internal", "verdict": "pass", "cited_id": "ci_3", "tier": "PUBLIC" },
    { "row": "relevant_positioning_with_external_context", "bucket": "external", "verdict": null, "cited_id": null, "tier": null }
  ]
}
FieldNotes
pack_idThe id of the context pack this call carried.
identityHow sure the pack is of who the recipient is: high, medium or low. Below high, nothing in the pack may be said as specific to the recipient.
rowsOne row per side of the pack that held something to check against: internal (Amdahl's own record of this recipient or of companies like theirs) and external (what the recipient's company or the market published). Only the sides that held something are asked.
rows[].verdictpass: the message is positioned on an item of that side, and the grader named it. fail: it is not. null: not measured — the grader did not answer, or it passed the row without naming an item it was shown. A null is never a pass.
rows[].cited_idOn a pass only: the id of that item. null otherwise.
rows[].tierOn a pass only: that item's tier (ACCOUNT, CRM, CALLER, SEGMENT, CORPUS or WORKSPACE for internal; PUBLIC or MARKET for external). null otherwise.

Report only. context_use never decides whether the optimizer tries a rewrite or asks you questions instead, and it is not part of lift or of the rule that stops the rewrites early.

The key is absent when the call carried no context pack, or when this deployment did not grade it. null in place of the object means the optimizer's answer could not be read. context_use is in beta.

Intake (beta)

Intake reads the text you paste about the recipient and turns it into fields, facts and rules. On most workspaces it is shown and not used: what it reads changes no field of the profile, no group of the brief, and nothing the optimizer receives. On a workspace where Amdahl has turned on intake use (beta, per workspace), a call that sends recipient also uses it, and mode says live; see Intake use. Check what it read, and when a value is wrong, fix your notes and run again. While intake is in this review phase, and only during the review window, Amdahl keeps the text it read and what it parsed, inside your workspace's data and readable by Amdahl staff only, so a person can check its accuracy; it is deleted 30 days after that review ends, at the latest 60 days after it was kept, or with your workspace.

It reads three inputs. In recipient.attributes, a key whose name is a profile field (Job Title, Company Name, Company Domain, Industry, Employee Count, Country and the like) is read as that field, by code. Every other attribute, your recipient_notes, and the CRM notes the brief read on the recipient's account (the newest 20, inside 4,000 tokens) go to one model call. Intake reads the account's CRM notes even when you send no notes and no attributes. Before that call, code reads email addresses and LinkedIn profile links as hints: a hint is never used on its own, because the address can belong to someone else, so it shows in unconfirmed unless the model or a named attribute gives the same value. Other links and domains in free text (a company page, a meeting or a document link) are never read as the recipient's domain. That call has 4 seconds. Intake reads at most 4,000 tokens, counted at 3 characters a token over the text as the model reads it, attribute names included (about 12,000 characters): your notes first, then the other attributes in the order you sent them, then the CRM notes, newest first; past that it stops and says so in truncated.

json
"intake": {
  "parsed": {
    "status": "ok", "mode": "shadow",
    "fields": [{ "field": "employee_count", "value": "140", "confidence": "high",
      "span": { "in": "recipient_notes", "start": 32, "end": 42 },
      "source_tag": "caller:extracted", "method": "model", "published_at": null }],
    "facts": [{ "value": "replacing Zendesk this quarter", "confidence": "high", "date": null,
      "span": { "in": "recipient_notes", "start": 74, "end": 104 },
      "source_tag": "caller:extracted", "method": "model", "published_at": null }],
    "rules": [{ "value": "Never mention pricing", "...": "..." }],
    "voice_examples": [], "thesis_hints": [],
    "unconfirmed": [{ "kind": "fact", "value": "series B", "confidence": "medium", "...": "..." }],
    "dropped": [{ "kind": "field", "field": "title", "value": "VP Engineering", "reason": "span_mismatch" }],
    "conflicts": [],
    "truncated": null, "model_called": true, "latency_ms": 1430
  }
}
KeyNotes
statusok; timeout when the model call passed its 4 seconds; model_error when it failed. In both, the values read by code (named attributes, address hints) are still returned. A timeout also returns the values the model had written before the deadline.
modeshadow: shown, not used. live: also used, by the rules in Intake use. A call with recipient_notes and no recipient is always shadow: there is no profile to fill.
fieldsProfile fields: email, name, linkedin_url, title, department, company, domain, industry, employee_count, country. At most one value each.
facts, rules, voice_examples, thesis_hintsWhat the recipient or their company said or did (with date when the text names one), your instructions such as "never mention pricing", a pasted sample of your writing, and your own angle ("my angle is ...").
confidenceOnly high values are in the lists above. A medium or low value is in unconfirmed, with its kind. An address hint read by code is medium, with method: "pattern".
spanWhere the value was read: character offsets [start, end) into recipient_notes, into the value of the attribute at position attribute (counted from 0 in the order you sent the map, never by its key), or into the CRM note whose brief record is record_id (its text is that record's value in brief.records). The text at the span always contains the value, as whole words.
source_tagcaller:extracted: you said it. crm:extracted: a CRM note on the account said it, and published_at is the note's date. Both count as what you or your CRM told us, never as a public source, and a fact or rule read from a note is a fact, never a quote.
droppedValues not kept, with the reason: no_span (the text does not hold it), span_mismatch (the text it points at does not contain it, for example a title written out in full when you wrote it short), unknown_field, over_limit, or account_note (a person field, such as a title or an email, read from a CRM note: a note belongs to the account and may be about someone else there). Intake never fills a field your text does not state.
conflictsA field with two or more different values in what intake read. None of them is in fields. In shadow none is used. In live, the order of trust can settle it: see Intake use.
truncatednull, or { "code": "input_truncated", "budget_tokens", "input_tokens", "kept_tokens" }.
output_cutlive only, and present only as true: the model's answer was cut, at its length limit (status: ok) or at the deadline (status: timeout). The values it wrote before the cut are shown, but no CRM note is replaced by its values. A long input (many CRM notes) can give more values than one answer holds: then the values come from your own text first, then the newest notes.

Intake use (beta)

On a workspace where Amdahl has turned on intake use, a call that sends recipient reads intake before it picks the group of similar buyers, so the call waits for intake's model call (4 seconds at most). While intake runs, the brief reads the group of the profile as it was before intake. When intake changes that group, the brief reads the new group with a 15-second limit of its own, so such a call can take up to about 19 seconds more. Then:

  • A high field joins the profile as a record tagged caller:extracted (your text) or crm:extracted (a CRM note). A title, an industry and an exact headcount also give the role level, the industry group and the company size, so they can pick the group of buyers in the brief.
  • The order of trust still holds: a field you send in recipient wins over an attribute whose name is a profile field, that attribute wins over your notes, your notes win over your CRM's own fields, and a CRM note's value only fills a field your CRM leaves empty. Another attribute (one the model reads) ranks with your notes. A value that disagrees is listed under the profile's conflicts, never merged.
  • When your text says what Amdahl already read (the same title as your CRM, for example), the profile keeps the record Amdahl read, with its source, and the optimizer still receives it.
  • A conflict inside intake is settled only by that order, never by confidence. When one value comes from a more trusted part than every other and it is high, it joins the profile and the other high values are listed under the profile's conflicts. Otherwise none of its values is used, and the field stays what Amdahl read.
  • A role level, industry group or company size that came from a value that lost its field is left out, so the group of buyers never comes from a value the profile does not show.
  • A domain is used only from an attribute whose name is a profile field (such as Company Domain). A domain the model reads from other text (a company page link, your notes) is shown and never used: a link can name someone else's site.
  • A medium value reaches the optimizer as a fact only, written as the field and its value (for example Department: Platform). It never picks the group of buyers. An email address, a LinkedIn link or a domain is never sent.
  • A high rule reaches the optimizer as a rule, after the rules you send in context. A high fact reaches it as a fact: from your text, as one of your own facts (with yours, at most 10); from a CRM note, as a CRM fact dated by the note.
  • A CRM note that intake read whole, and in which it found at least one fact or rule it uses, reaches the optimizer as those facts and rules instead of its whole text. Any other note is sent whole, as before, and none of its values is used: a note intake cut, a note read when the model call did not answer or its answer was cut (output_cut), and a note in which it found nothing it uses.
  • A low value, a voice example and a thesis hint are shown and not used.
  • The response's context still counts only what you sent in context: the rules and facts intake adds are not in it, and a call that sent no context gets none. veto_reason: breaks_rules can come from a rule intake read in your text or in a CRM note.

Response

Written from context, a run returns kept only when your draft already did the job. Every run that sends facts or rules writes a message, starting from your draft when you send one, so outcome is rewritten on success. When the message that comes back is your draft with at most its greeting, sign-off or spacing changed, outcome is kept, unchanged is true, message holds the returned text (the change included), and a sender note with code draft_kept names the change. For example: Your draft already met every rule you sent. The only change: added the greeting "Hi Jacob,". kept_suggestions comes only from a run that improves a draft: one sent with no facts, no rules and no recipient, or one on a deployment that still improves every draft. What your input cannot back is left out of the message and listed in omitted; see When it cannot write a message. The example below is from a run that improved a draft, so some of its fields and notes read differently.

Like every operation, the result is inside the data envelope:

json
{
  "data": {
    "ok": true,
    "outcome": "rewritten",
    "rewrite": "Hi Chris, thanks for connecting. I noticed you lead RevOps at Brightpath. We work with RevOps teams to streamline their tech stack and drive pipeline with AI. Curious if you're dealing with either of those right now?",
    "message": "Hi Chris, thanks for connecting. I noticed you lead RevOps at Brightpath. We work with RevOps teams to streamline their tech stack and drive pipeline with AI. Curious if you're dealing with either of those right now?",
    "summary": "Rewrote it in your own style to improve how clear the ask is and how human it sounds. This was a style pass only. Its claims were not checked against your workspace's conversations.",
    "unchanged": false,
    "run_id": "ad5a34f6-...",
    "extraction": { "receiver_name": "Chris", "company": "Brightpath", "channel": "linkedin" },
    "lift": null,
    "evidence": { "requested": "off", "used": false, "reason": "not_requested" },
    "on_unsupported": "flag",
    "unsupported_claims": null,
    "notes": [
      {
        "code": "rewrite_checked_yes_no",
        "audience": "sender",
        "says": "Re-checked independently (a different model from the one that picked the rewrite) on yes/no questions, each asked twice of the draft and of the rewrite: it did not consistently lose a clear ask, a human tone or concision, and it was not found to add a claim the draft did not make. This is a pass/fail check, not a before-and-after score."
      },
      {
        "code": "style_pass_only",
        "audience": "sender",
        "says": "This was a style pass only. The draft was graded on how it reads, and its claims were not checked against the workspace's conversations. A style pass does not mean that the draft passed or that it is ready to send."
      },
      {
        "code": "evidence_available",
        "audience": "developer",
        "says": "This request did not ask for evidence, so the draft was graded on style alone. This deployment can read the workspace's conversations: to also grade the draft against them, send evidence: \"workspace\"."
      }
    ]
  }
}

That is a real response to a LinkedIn draft sent with no evidence, with its notes as they read now and one developer note left out: a style pass. Its claims were not checked (unsupported_claims: null), and lift is null, because a style pass measures no before-and-after score; a different model re-checked the rewrite on yes/no questions instead, which is what the first note says. With evidence: "workspace" on a workspace with synced conversations, evidence.used is true, unsupported_claims is a list ([] when nothing was found), and a rewrite that reaches the blind re-grade carries a lift measured against about 20 of your customers' quotes. Optimize a cold email reads a full response, and a kept draft, field by field.

FieldNotes
okfalse means no rewrite was produced. Read reason (or error.code for the monthly cap) and stop.
outcomeWhat happened, in one field. rewritten: a message came back. needs_input: nothing new was written, and ask holds the questions. Written from context, this comes only with on_missing_input: "ask", when your input could not support the request, and there is no message. A run that improves a draft returns it when the draft is too weak to improve, with the draft unchanged in message. With the default, best_effort, a run written from context always carries a message, and omitted lists what it could not do. kept: your draft came back, as written or, written from context, with only its greeting, sign-off or spacing changed (a draft_kept note names the change). Read this first.
rewriteThe message, and only a message the Optimizer wrote: null whenever outcome is not rewritten.
messageThe version to use. Use it as returned; do not polish it with another model, which undoes the checks that kept your facts exact. On a run that improves a draft, this can be your own text, unchanged. Absent, never null or "", when outcome is needs_input and nothing came back.
summaryOne line naming what the message aimed at. It says the message scored higher than your draft only when lift.returned is rewrite. Written from context with a draft, it is built from the changes to your draft: the one change when the draft was kept, otherwise how many of its sentences were added, changed and removed.
what_changedOne plain sentence for the person who wrote the draft, at most about 30 words, on what the rewrite changed and why: the sentences it cut (naming the opener and the ask), whether it replaced the ask, whether the subject line was rewritten to fit a body it no longer matched, and, when it cut lines and you sent no facts, that a fact would keep them. For example: Cut the opener and 3 more sentences, replaced the ask and rewrote the subject to match; no fact you sent backs the cut lines, so add one to keep them. Built from the changes to your draft, with no model call. Present only when outcome is rewritten, and only from an Optimizer that sends it. Show it beside message with the sender notes; it is the Optimizer's own reason for the changes.
unchangedtrue only when outcome is kept (your draft came back, at most with its greeting, sign-off or spacing changed), or when nothing was written (see ask). Do not present that as a new message. Absent, like message, when no message came back.
extractionWhat the optimizer read out of the draft: receiver_name, company, signal and channel. With no draft, it holds at most channel. Read who the message is for from your own context instead.
liftThe only before-and-after in the response, so the only score to quote as one. A different model from the one the rewrite was optimized against re-grades your draft and the rewrite blind, one call each and neither told which is the draft, against the same evidence. Measured only when the run read your conversations and a rewrite reached that re-grade; null otherwise, which includes every style pass. See Reading lift.
evidenceWhat happened about customer evidence on this run: { "requested", "used", "reason" }. See Reading evidence. null only from an optimizer that predates the field.
notesFacts about this run, each { "code", "audience", "says" }. Branch on code, never on the wording of says. audience says who says is written for. A sender note is about the draft or the message that came back, in plain words: show it to the person who wrote the draft, as written. A developer note says how to read this response, such as which grader decided or what to send next, and can name fields and parameters: act on it or log it, and never show it to the sender. A code has the same audience on every run, so filter on the field rather than on a list of codes, and treat any audience other than sender as not for the sender. New codes appear over time. Omitted when there are none.
vetoed_round, veto_reasonPresent only when the optimizer refused its best rewrite and returned your draft, and reports that it did (not every deployment reports it yet; unchanged: true says your draft came back either way). vetoed_round is the rewrite round it refused. veto_reason says why, as one of these values. not_better: on a run that read your conversations, the rewrite did not score higher than your draft on the blind re-grade. atomic_regrade_consistent_fail: on a style pass, the independent yes/no re-check found it consistently worse on one question, for example less concise, or found that it added a claim that neither the draft nor your facts make. breaks_rules: it broke one of your rules when re-checked (on a workspace with intake use, a rule intake read counts too). The optimizer is moving to a check that refuses a rewrite only for a specific problem, and then names it: conflicts_with_callers_ask (it asks for a different length or time than the ask you stated), drops_ask (it leaves out the ask you stated, or the ask your draft made), adds_claim (it states something neither your draft nor your facts support), drops_entity (it drops a name, link or placeholder your draft had, or the offer you stated), inflates (it goes over your word limit, or grows a short draft a lot when nothing you sent asked for more), and no_gain (it changed only how the draft reads, and not by enough to be sure it reads better). The run's notes carry the specifics. Treat a value you do not recognise as a refusal.
on_unsupportedThe mode this run used, flag or remove, so a caller that sent none can see the default it got.
on_missing_inputThe mode this run used, best_effort or ask. null only from an Optimizer that predates the field.
omittedWhat the message could not do with your input (a fact a rule needs, a rule it does not fully follow, a missed limit), each { "what", "why" }. [] means it did everything your input asked. Absent only from an Optimizer that predates the field, which does not say. See Reading omitted.
placeholdersThe unfilled slots message still holds, such as [Last Name], {first_name}, <NAME>, XX% or TBD, each { "placeholder", "asks", "source" }. [] means it holds none. If it is not empty, fill each slot from what you know, or put its asks to the sender, before the message is sent. The message comes back as it is, and outcome is unchanged. Absent only from an Optimizer that predates the field. See Reading placeholders.
unsupported_claimsClaims in the message that nothing in your conversations backs and no fact states, each with what happened to it in message. [] means they were checked and none was found; null means they were not checked on this run, which is always the case without evidence: "workspace" and whenever evidence.used is false. See Reading unsupported_claims.
kept_suggestionsOnly on a run that improved a draft and returned it unchanged: what you can change yourself, each { "kind", "span"?, "says" }. A run that writes from context never carries it. See Reading kept_suggestions.
askPresent only when nothing new was written: the questions only the sender can answer, at most three, each { "asks", "field"? }. Written from context, only with on_missing_input: "ask", when your input could not support the request. A run that improves a draft sends them when the draft is too weak to improve. Omitted otherwise. See Reading ask.
triesPresent only when you sent include_tries: true: every version the run scored or checked, in order, each { "round", "message", "score", "human_tone", "aimed_at", "returned" }, plus source, checks_open and sentences_removed on a version written from context. null when the run's trace could not be read; the rewrite is unaffected. See Reading tries.
recipient_received, evidence_run_idBeta. Present only when you sent a recipient field: what arrived, as names and counts, and an id of this run's own.
cited_evidenceBeta. Present only when the optimizer received something from the recipient's profile or brief: the brief quotes its grader cited on message, each { "id", "tier" }. See What the optimizer receives.
fit_for_audienceBeta. Present only when the brief the optimizer received holds a pain or an objection of buyers like the recipient, or the account's history, and the optimizer graded the line: whether message addresses one, as { "verdict", "cited_id", "tier" }. Report only. See Fit for the audience.
context_useBeta. Present only when the call carried a context pack and the optimizer graded it: whether message is positioned on something from Amdahl's own record of the recipient or of companies like theirs (internal), or on something the recipient's company or the market published (external), as { "pack_id", "identity", "rows" }. Report only. See Context pack use.
contextPresent only when you sent a context: how many rules and voice examples the optimizer received once blank entries were dropped, as { "rules": n, "voice_examples": n }, plus "facts": n when you sent any, so you can confirm they arrived. Each constraint you sent is echoed too: keep_cta and max_words as sent, banned_phrases and banned_punctuation as counts. Counts only, never the text. The rules and facts that intake use adds are never counted.

Reading lift

lift compares the returned message with your draft. A message written from context has no before to compare, with or without a draft: expect lift to be null and read the rewrite_checked_yes_no note instead.

FieldNotes
before, after, deltaThe re-grade's 1 to 5 means for your draft and for the rewrite, and the difference.
returnedrewrite, or draft when the rewrite did not score higher than your draft on the re-grade (a tie counts as not higher) or broke one of your rules when re-checked. With draft, your draft came back: message is your own text and unchanged is true.
dimensions, quotesWhat was measured: the rubric dimensions both gradings answered, which include the four evidence dimensions, and how many of your customers' quotes they read, typically around 20. Read both before repeating the number: a lift is a claim about those dimensions and those quotes, no more.
method, grader, grader_version, rewrite_roundHow it was measured, by which model, and which rewrite round was compared. method is always blind_regrade_pinned. Compare two lifts only when their grader_version matches.

lift is null when no before-and-after was measured, and then none is claimed. That is always the case on a style pass (evidence off, or requested and not used): there a returned rewrite is re-checked by a different model on yes/no questions instead, each asked twice of both versions, and the rewrite_checked_yes_no note says what it found. On a run that read your conversations, lift is also null when no rewrite reached the re-grade, the re-grade failed, or its two gradings did not answer the same dimensions. If unchanged is false, lift is null and no note says the rewrite was re-checked, the rewrite was chosen by the optimizer's own judge alone.

Reading evidence

FieldNotes
requestedThe mode the run was asked for: workspace or off, with the default applied when you sent none.
usedtrue only when your conversations were read and held quotes to grade against. Say a draft was checked against your customers only when this is true.
reasonPresent when used is false. not_requested: you did not ask. evidence_unavailable: this environment does not read customer conversations, so the run was graded on style alone; notes carries the same code as a developer note. corpus_empty: the workspace has no conversations to read yet. evidence_read_failed: the read failed, so the four evidence dimensions were not graded; a retry may succeed. None of these is an error: the rewrite came back.

When you sent evidence: "workspace", no conversations were read and the workspace has no connected source, the result also carries setup_needed: a message and a connect_url that tell the user where to connect one. The rewrite is still returned. A call without evidence: "workspace" needs no data and never carries it. See A workspace with nothing connected.

Reading unsupported_claims

json
"unsupported_claims": [
  {
    "span": "we cut onboarding time 40% for teams like yours",
    "kind": "about_recipient",
    "why": "No quote shown names an onboarding figure.",
    "dimension": "verified_specifics",
    "status": "kept",
    "fix": [
      { "action": "add_fact", "how": "if it is true, re-run with context.facts: [\"we cut onboarding time 40% for teams like yours\"]" },
      { "action": "add_evidence", "how": "cite a source, or optimize again with evidence \"workspace\"" },
      { "action": "rephrase" }
    ]
  }
]
FieldNotes
spanThe claim, copied exactly from the message.
kindabout_recipient (it talks about the person or company you are writing to) or product_or_outcome.
whyOne sentence on why nothing backs it.
dimensiongrounding, or verified_specifics when the claim carries a number.
statusWhat happened to it in message: kept, removed, or rewrite_rejected (it is still there because a rewrite that dropped it was not returned).
fixWhat can be done about it: add_fact, add_evidence, and rephrase, which carries a suggestion when the grader proposed a wording. The how text comes from the Optimizer as written.

At most 10 claims are listed.

add_evidence's how text always offers optimizing again with evidence: "workspace". When the run already asked for it and read nothing because there was nothing to read (evidence.requested is workspace, used is false, and reason is corpus_empty or evidence_unavailable), another run reads nothing too. Add a source to facts inside context instead, or connect a data source so the workspace has conversations to check against (setup_needed says where).

If you are an agent relaying this to a person, do not call the message ready while a claim is still in it. Show the user each claim and ask:

  1. Is it true, and do you know it first-hand? Re-run with it in facts inside context, in the user's own words. A fact that states the claim clears it; one that says less does not.
  2. Does it have a source? Add the source to facts inside context and re-run.
  3. Neither? Offer to re-run with "on_unsupported": "remove", or let the user reword it.

Never re-run with remove without asking: the claim may be something the user knows and the conversations do not.

Reading kept_suggestions

This applies only to a run that improves a draft. A run that writes from context never carries kept_suggestions, even when it starts from your draft. When every rewrite was refused, or none scored higher than your draft, your draft comes back with unchanged: true. kept_suggestions is what you can change yourself instead, most useful first, at most 5:

json
"kept_suggestions": [
  {
    "kind": "back_up_or_cut_claim",
    "span": "we cut onboarding time 40% for teams like yours",
    "says": "Cut this claim or back it up with something you can point to: \"we cut onboarding time 40% for teams like yours\""
  },
  {
    "kind": "add_recipient_specific",
    "dimension": "relevant_positioning",
    "why": "Nothing in the draft is about this recipient.",
    "says": "Add one specific fact you actually know about the recipient (something they said, did or published) and tie the offer to it. Leave it out rather than guess."
  },
  {
    "kind": "shorten",
    "says": "Shorten to about 91 words (under 600 characters); it is 160 words (1050 characters) now."
  }
]
FieldNotes
kindWhat sort of edit, to branch on: back_up_or_cut_claim, add_recipient_specific, make_distinct_point, clarify_ask, needs_real_proof, shorten, replace_generic_line, rewrite_sentence_shape or replace_dash. New kinds may be added, so treat an unknown one like any other row.
spanPresent when the edit is about one part of the draft: that text, copied exactly from what you sent.
saysThe edit, in fixed wording that is safe to repeat to a person as written.
dimensionPresent when the edit comes from a failed rubric line: which one.
whyPresent with dimension: the grader's reasoning. Context for the edit, never wording to put in the message.

The list costs no extra model call: every row comes from something the run already measured. Nothing in it invents a fact. Only span and the counts in shorten come from your draft, and a phrase a refused rewrite made up is never repeated.

Your rules come first. When you send rules inside context, a suggestion about a part of the message that a rule names is left out, and the next one takes its place. The parts are the ask (clarify_ask), the recipient (add_recipient_specific), the pitch (make_distinct_point), customer proof (needs_real_proof), the length (shorten) and dashes (replace_dash). A suggestion with a span is left out when a rule repeats four or more words in a row from it. So "End by asking for a 15-minute call." removes clarify_ask, and "Never mention pricing." removes nothing. The check reads the words of each rule, so it cannot see a rule that decides a part without naming it. If a suggestion still goes against one of your rules, the rule wins.

Reading ask

With on_missing_input: "ask", the Optimizer checks before it writes whether your input can support the request as asked. When it cannot, a message could only make up the missing details, so it writes nothing. ask lists what it needs from the sender, at most three questions:

json
"ask": [
  { "asks": "Who is this for? Which account, and which person there?" },
  { "asks": "What did you see or hear that prompted this? A call, a post, a launch, a number." },
  { "asks": "What specifically changes for them if they say yes?" }
]
FieldNotes
asksThe question, for the sender.
fieldPresent only when the optimizer's grader named an input it needed, for example account. Most questions carry none, as above. Treat it as an open string: new inputs may be added.

Put the questions to the sender as written. Then add their answers to the facts of context and run it again. The asked-for-input note, which has the sender audience, says why nothing was rewritten. The response may also carry kept_suggestions. Show the questions first.

With on_missing_input: "ask", a run that stops before it writes carries a field on each question: the part of context its answer goes in, its facts or its rules. Its omitted lists the same parts, each with why.

Reading omitted

omitted lists what the message could not do with your input. Each row is one of three things: a fact one of your rules needs that none of your facts gives, a rule the message does not fully follow, or a requirement it still misses, such as your word limit:

json
"omitted": [
  { "what": "The customer count the second rule asks for", "why": "None of the facts gives a customer count." }
]
FieldNotes
whatWhat the message could not do: the fact a rule needs, the rule it does not fully follow (quoted), or the requirement it misses.
whyOne plain sentence, written for the sender. It names no field.

Show each why to the sender beside the message. To fix a row, add the fact it names, or change the rule, and run it again. omitted: [] means the message did everything your input asked. A row is never a reason to edit the message by hand: the checks ran on the text as returned.

Reading placeholders

placeholders lists each unfilled slot the returned message still holds. Sent as is, the slot reaches the recipient:

json
"message": "Hi Bo,\n\nAdam [Last Name] ran a large Sales Navigator deal with you at Acoustic. Worth comparing notes?",
"placeholders": [
  { "placeholder": "[Last Name]", "asks": "What is Adam's last name?", "source": "writer" }
]
FieldNotes
placeholderThe slot, exactly as message holds it. Replace this text.
asksOne plain question that names the missing fact. Safe to put to the sender as written.
sourcewriter: only the returned message holds it. caller: your own draft, facts, rules or voice_examples already held the same slot, for example a merge field your sending tool fills or a link you paste later.

Before the message is sent, fill each slot from what you know, or ask the sender asks and put the answer in its place. This is the one edit to message you should make. You do not need to run it again to fill a slot. The unfilled_placeholders note, which has the sender audience, says the same thing to the sender. The Optimizer finds slots with code, not a model. A markdown link, [sic], a footnote and text in code are not slots. A slot does not change outcome or what the call counts toward your cap. A batch item's result carries the same field.

When the subject line no longer fits

When your draft opens with a subject line (Subject: ...) and the rewrite cut what that subject was about, the Optimizer writes a new subject from the rewritten body and puts it in message. A sender note with code subject_rewritten says so and quotes your old subject, and what_changed ends its list of changes with "rewrote the subject to match". When it cannot write one it can stand behind (one short line that names nothing the body does not), your subject stays and a sender note with code subject_mismatch says it may no longer fit; check it before sending. A subject the rewrite changed itself is left as it is.

Reading tries

Send include_tries: true to see the path, not just the result. Each round is one version, and each later round is aimed at every dimension the round before it scored below the top:

json
"tries": [
  { "round": 0, "message": "Hi Dana, one national campaign, 380 dealers...", "score": 3.8, "human_tone": 4.7, "aimed_at": [], "returned": false },
  { "round": 1, "message": "Hi Dana, I know the challenge...", "score": 3.4, "human_tone": 3.84, "aimed_at": ["cta_clarity", "length_discipline"], "returned": false },
  { "round": 2, "message": "Hi Dana, 380 dealers all needing their own version...", "score": 4.2, "human_tone": 4.86, "aimed_at": ["human_tone"], "returned": true }
]
FieldNotes
roundThe round, in order. When you sent a draft, round 0 is that draft. With no draft, round 0 holds no message of yours.
messageThe body that round produced. A sign-off is set aside before any rewrite and put back only on the returned message, so a try never shows it.
scoreThe Optimizer's own score for that version, 1 to 5. null when it could not score that round.
human_toneHow much that version reads like a person wrote it, 1 to 5. null when the round was not scored.
aimed_atThe dimensions this round was told to improve, from the scores of the round before it, using the names in What it grades plus human_tone, length_discipline, user_preference and cta_clarity, and fit_for_audience when the brief holds a point it can rest on (Fit for the audience). Empty on round 0 and on a version written from context.
returnedtrue on the one version that came back as message. When your draft came back, that is round 0, even if a later round scored higher and was refused.
source"compose" on a version written from context. Absent on every other try.
checks_openOnly on a version written from context: how many checks that version still fails. A check counts as open when the Optimizer could not read it, so 0 means every check was read and passed.
sentences_removedOnly on a version written from context: how many sentences the Optimizer removed because no fact you sent supported them.

A version written from context is checked, not scored. When you send context facts, the Optimizer writes several versions side by side and checks each one: the exact requirements (word limit, banned words and punctuation, greeting and sign-off), every sentence against your facts, and your rules. It repairs what fails and removes a sentence that no fact supports. It does not score these versions, so their score and human_tone are null. One exception: on a call with recipient fields, the Optimizer grades the returned version after it chooses it, so that try has a score. Use checks_open to compare the versions. The Optimizer prefers fewer failed exact requirements first, then fewer open checks. A rule that needs a fact you did not send is not counted in checks_open: the returned version lists it in omitted instead.

Read score as the path, not as proof. It comes from the same grader every rewrite was written against, and that grader picked the winner, so a rise from round 0 to the returned round overstates the gain. When the response carries fit_for_audience, score includes that line too. Scores also go down between rounds; that is the search working, not a fault. lift is still the only before-and-after to quote. Rewrites the Optimizer discarded before scoring, for example for changing a name or a number, are not listed; notes says when that happened.

When ok is false

reasonWhat happened
optimizer_unconfiguredThe optimizer does not run in this environment. Nothing was attempted.
optimizer_timeoutIt did not answer in time. Nothing was written; a retry costs a fresh run.
optimizer_errorIt answered with an error. Nothing was written.

optimizer_timeout and optimizer_error also carry retry_after_seconds (60) and stop_batch: true:

json
{
  "data": {
    "ok": false,
    "reason": "optimizer_error",
    "detail": "The message optimizer could not be reached. Nothing was rewritten. Stop sending drafts. Wait 60 seconds, then send one draft to check before you send the others.",
    "retry_after_seconds": 60,
    "stop_batch": true
  }
}

When you run a list of drafts and get one of these, stop the list. The other drafts would fail the same way. Wait retry_after_seconds, send one draft, and continue only when it succeeds. A failed call is not billed.

A call refused by the monthly cap carries ok: false, success: false and error.code: "quota_exceeded" instead of a reason, and returns 429 over REST.

A call refused because too many optimizes are running or were started has the same shape with error.code: "rate_limited". It is the one failure that is safe to send again unchanged, after the wait in error.details.retry_after_seconds. A call refused because the Optimizer is down has that shape too, with error.details.reason: "optimizer_unavailable". If you get a refusal for a limit often, you are sending more drafts than single calls are for: send a batch.

The failure branch carries no message at all, so there is nothing to mistake for a written message. Keep the context you sent: send the same request again when you retry.

When it cannot write a message

The Optimizer never returns a version that a check refused, and it never makes up what your input does not give. With on_missing_input set to best_effort, the default, it writes the best message your input supports, leaves out what it cannot back and lists that in omitted. With ask, it returns the questions instead when your input cannot support the request as asked.

There is no unchecked fallback, in either mode. Written from context (every call that sends facts or rules, with or without a draft), the Optimizer turns your rules into one set of requirements before it writes, and writes two versions in which every sentence names the facts it rests on. When you sent a draft, both start from it, and a sentence can rest on what the draft states. Code fixes the mechanical marks (a dash, an exclamation mark, a sign-off line) and checks your constraints. A model reads each sentence against your facts and the rules a reader must judge. Only the sentences a check named are rewritten, at most twice, and a sentence that is still unsupported is deleted. The version with the fewest open problems comes back, and what it still could not do is in omitted.

Improving a draft (a call that sends a draft and no facts, no rules and no recipient), the Optimizer scores each version it writes and keeps the one that scored highest. A version is thrown away when:

  • it breaks one of your rules or constraints;
  • it adds a figure, a name or a claim that none of your facts and none of the evidence Amdahl read states;
  • it still holds a placeholder, such as [Sender name], or is otherwise not ready to send.

The best version then faces an independent re-check by a different model. On a style pass the re-check asks yes/no questions, twice each, and refuses the version when it is consistently worse on one of them or adds a claim that your facts do not make.

A run with on_missing_input: "ask" that stops before it writes returns ok: true, outcome: "needs_input", no message, and ask with the facts or rules to add. That run is not billed. Nothing you sent is lost: send the same request again with the answers as facts.

A round the judge could not score is excluded from the comparison rather than treated as a zero.

Read a past run

GET /messages/optimize/runs/{id} returns one run you made, by the run_id the optimize returned. It holds the version you sent in message, the context you sent, and the message that came back, so you can show the two versions again later, for example to a colleague or a client after an agent's first run.

bash
curl -s https://app.amdahl.ai/api/platform/v1/messages/optimize/runs/0b6a6c1e-4f5d-4c1a-9d1e-2f3a4b5c6d7e \
  -H "X-API-Key: $AMDAHL_KEY"
json
{
  "data": {
    "run_id": "0b6a6c1e-4f5d-4c1a-9d1e-2f3a4b5c6d7e",
    "draft": "Hi Priya, I saw Acme started a trial. Worth a 15 minute call next week?",
    "context": { "facts": ["Priya Shah is VP Sales at Acme."], "rules": ["Ask for a 15 minute call next week."] },
    "message": "Hi Priya, ...",
    "created_at": "2026-10-20T10:00:00Z"
  }
}
FieldMeaning
draftYour own version, as you sent it in message. null when you sent none.
contextThe context you sent. null when you sent none.
messageThe message Amdahl returned. null when nothing was written.

Only the sender can read a run. A run is stored with your workspace and your user. Another member's run, another workspace's run, an unknown id and a malformed id all return the same 404 not_found. It needs messages:execute, the scope that ran the optimize.

Record feedback on a run

POST /messages/optimize/runs/{id}/feedback (MCP: record_message_feedback) says what happened to the message one run returned. Send evidence, and at least one of sent_text, comment and outcome:

FieldRequiredMeaning
evidenceyesHow you know: did_it_myself (you sent or edited the message), reported_to_me (the sender told you) or inferred (you concluded it). These are three kinds of observer, not a confidence scale. An inferred edit is stored and scored, but it does not train the sender's preferences.
sent_textone of these threeThe message as the sender actually sent it, verbatim, edits included. This is the edit the optimizer learns the sender's tells from.
commentone of these threeWhat the sender said about the message, in their own words.
outcomeone of these threeused, used_with_edits, not_used or unsure.
bash
curl -s -X POST https://app.amdahl.ai/api/platform/v1/messages/optimize/runs/0b6a6c1e-4f5d-4c1a-9d1e-2f3a4b5c6d7e/feedback \
  -H "X-API-Key: $AMDAHL_KEY" -H "Content-Type: application/json" \
  -d '{"evidence": "reported_to_me", "outcome": "used_with_edits", "comment": "Too much flattery.", "sent_text": "Hi Priya, ..."}'

It returns:

json
{ "success": true, "feedback_id": "...", "run_id": "...", "rubric_miss": false, "outcome": "used_with_edits", "evidence": "reported_to_me" }

rubric_miss is true when the current judge scored sent_text below the message the run returned: the judge and the person disagree. It is null when you sent no sent_text. outcome and evidence are as stored. As with reading a run, only the sender can send feedback on it: another member's run, another workspace's run and an unknown id return 404 not_found, and nothing is stored. A call with no evidence, or with none of the other three fields, is a 400 invalid_argument. Each call stores one more event, so a retry stores it twice. It needs messages:execute and does not count toward the monthly cap.

Whose voice it learns

The optimizer keeps a per-sender ledger of tells: small, individually checkable facts about how you write, like "opens with a question". It learns them from the edits you make to what it gives back.

  • The ledger is per workspace and per sender. The identity is composed server-side from your workspace and your user, and a request cannot name it.
  • It learns from feedback. The sent_text of feedback on a run is the edit it learns from.
  • It has nothing to go on at first. A new sender gets rewrites scored on the base rubric alone.

Latency

A single optimize usually takes 1 to 2 minutes: it runs the full search and grades every candidate before it answers. (A batch answers at once and runs its drafts later.) With evidence: "workspace" each grading also reads your conversations, so it takes longer. A call can run for up to 300 seconds before it gives up with optimizer_timeout, and it returns on the same call, so set your client timeout to at least 310 seconds. A call that sends recipient first resolves the profile and builds the brief (up to about 41 seconds more, or about 58 when intake use is on and intake changes the group of similar buyers), so allow 350 seconds for those calls, or 370 seconds where intake use is on. If your connection closes before the answer arrives, the run still finishes, and the same request again returns its result. The operation declares its expected latency on the operation catalog, so a client can budget for it.