molted send
Send an email via the agent API
| Command | Description |
|---|---|
molted send | Send an email via the agent API |
molted send batch | Send a batch of emails |
molted send simulate | Dry-run a send: the real send's checks, nothing delivered or recorded (accepts shorthand or template payload) |
molted send simulate-batch | Simulate a batch send |
molted send propose | Propose an email (template candidates + policy pre-check) |
Agent context
Send is the core command for delivering email through Molted. Every send goes through the policy engine (rate limits, suppressions, consent, cooldown) before reaching the mail provider. The CLI handles tenant injection automatically.
Quick send (shorthand — uses _default template, auto-generates dedupe key):
molted send --to alice@example.com --subject "Hello" --body "Hi Alice!"
# → { "status": "queued", "requestId": "<uuid>", ... }
molted send --to alice@example.com --subject "Hello" --html "<h1>Hi Alice!</h1>"
# → same, but with HTML bodyRecommended pattern -- simulate before sending
molted send simulate --to alice@example.com --template welcome \
--payload '{"name": "Alice"}'
# → human-readable policy debug on stderr + JSON on stdout
# Shows: verdict, from address, rate limits, suppression, full policy context
molted send --to alice@example.com --template welcome \
--dedupe-key "welcome-alice-1" --payload '{"name": "Alice"}'
# → { "status": "queued", "requestId": "<uuid>", ... }Note: "send simulate" and "send --dry-run" both run a policy check without delivering. Use whichever fits your workflow -- they call the same API.
Template discovery with policy pre-check
molted send propose --to alice@example.com --context '{"topic": "renewal"}'
# → returns your active templates, a policy pre-check, and contact context
# (--context is accepted but not read by the API today)
# then pick a template and send with your own payload
# policyPreCheck.notChecked lists what the pre-check could not judge without
# a template (cooldown, duplicate, consent, ...): "allow" is not a promise,
# so run "send simulate" with the chosen template before you sendDedupe keys (critical for agents):
--dedupe-keymakes a send idempotent: one send request per key per workspace- Same key on a second call → { "status": "blocked", "reason": "duplicate" }
- The first request uses the key up even when it was blocked: after fixing a block, send again with a new key
--idempotency-key, when given, is used as the dedupe key instead- Use deterministic keys: {workflow}-{contact}-{step} (e.g. "onboard-alice-day1")
- Interactive mode (-i) auto-generates a random key if you skip it
Batch sending
molted send simulate-batch --template promo --recipients '["a@b.com","c@d.com"]'
# → per-recipient policy results (check each one)
molted send batch --template promo \
--recipients '[{"email":"a@b.com","dedupeKey":"promo-a","payload":{"name":"A"}}]'
# or pipe from stdin: cat recipients.json | molted send batch --template promo --stdinUnderstanding the response ("status" field, never "sent"):
- "queued" - accepted, headed for delivery
- "scheduled" - accepted but deferred (e.g. domain warmup); check "scheduledAt"
- "pending_approval" - held for a human decision (mailbox autonomy level, thread risk, content check)
- "blocked" - policy denied it; check "reason" (exit code is still 0!)
Always check the status field. A blocked or held send is not an error, it's policy working. A retry with the same --dedupe-key returns "status": "blocked", "reason": "duplicate", and the original requestId (not a new send).
Common block reasons: suppressed, no_consent, trial_not_activated, duplicate, template_not_found, template_render_failed, mailbox_not_active, sender_domain_not_verified, hourly_limit_exceeded, daily_limit_exceeded, monthly_limit_exceeded, overage_cap_exceeded. A blocked/held/scheduled response always prints a next step for its reason on stderr.
Send with follow-up
molted send --to alice@example.com --template outreach \
--dedupe-key "outreach-alice-1" --payload '{"name":"Alice"}'
# → note the requestId in the response
molted threads followup <requestId> --contact-email alice@example.com \
--delay-hours 72 --cancel-on-replyHumanizer controls (per send)
--no-humanize: Send exactly as written, always honored.--humanize: Ask for the humanizer. Ignored if the account has it off (the response carries a warning); use--no-humanizeto force it off instead, that always works.--humanize-style <style>: casual, professional, friendly. Used whenever the humanizer runs on this send, whether that is because of--humanizeor an account/agent/mailbox setting.
molted send --to alice@example.com --template outreach \
--dedupe-key "outreach-alice-1" --payload '{"name":"Alice"}' --no-humanizeSend exactly what you previewed
molted humanizer preview --subject "Hello" --body "Hi Alice, ..."
# -> shows the rewrite and a previewId (valid 24 hours)
molted send --to alice@example.com --subject "Hello" --body "Hi Alice, ..." \
--humanize-preview <previewId>
# -> delivers that exact rewrite: no second rewrite, no second humanizer credit.
# Pass the same --body/--html (or --template/--payload) you previewed: other
# content is refused with 409 humanize_preview_mismatch. An unknown or expired
# id is a 404. An id sends once: a second send with it is 409
# humanize_preview_used. If the humanizer was turned off since, the original
# goes out (warning humanize_preview_ignored_humanizer_off). The subject is
# never rewritten, and the preview's style is the one delivered.Pre-send checklist
molted billing status- Look for: "sendBlocked: false" — if true, no sends will go through until billing is resolved
molted budget- Look for: remaining quota > 0 on monthly, daily and hourly
- If any window is exhausted, sends are blocked with reason "hourly_limit_exceeded", "daily_limit_exceeded", "monthly_limit_exceeded" or "overage_cap_exceeded"
molted analytics fatigue --contact <email>- Look for: a low fatigueScore and what "recommendation" says
- High fatigue scores mean the contact has received too many emails recently (advisory: the send path does not check it)
molted send simulate --to <email> --template <id> --payload '<json>'- Look for: "wouldAllow: true" in the output
- Shows the full policy verdict: rate limits, suppression, consent, cooldown
- Fix any BLOCKED reasons before sending (or use
--dry-runon the send command)
molted send --to <email> --template <id> --dedupe-key <key> --payload '<json>'- Add
--reply-to <address>when replies should go to someone else (e.g. a contact form visitor), not back to the mailbox - Check the response: "status: queued" or "scheduled" means accepted; "status: blocked" means policy denied it; "status: pending_approval" means it's waiting on a human decision
- None of blocked/pending_approval/scheduled is an error exit code, always check the status field
- Add
Related: molted templates, molted mailboxes, molted threads, molted analytics, molted billing, molted budget
Commands
molted send
Send an email via the agent API
molted send [options]| Option | Description | Default |
|---|---|---|
-i, --interactive | Interactive mode — walk through fields with prompts | |
--to <email> | Recipient email address | |
--template <slug> | Template slug (e.g. welcome, _default) | |
--dedupe-key <key> | Deduplication key | |
--payload <json> | Template payload (JSON string) | |
--reason <reason> | Reason for sending | |
--mailbox <id> | Mailbox ID | |
--idempotency-key <key> | Idempotency key: when given, used as the dedupe key instead of --dedupe-key | |
--attachments <json> | Attachments: JSON array of {"id": "<attachmentId>"} from molted attachments upload | |
--subject <subject> | Email subject (shorthand: uses _default template) | |
--body <text> | Email body as plain text (shorthand: uses _default template) | |
--html <html> | Email body as HTML (shorthand: uses _default template) | |
--dry-run | Policy check without delivering (calls the same API as send simulate; only --to, --template or --subject/--body/--html, --payload, --dedupe-key and --mailbox are passed) | |
--humanize | Ask for the humanizer on this send. Cannot turn it on if the account has it off; the response carries a warning when ignored. | |
--no-humanize | Force this send out exactly as written, always honored, even if the account has the humanizer on. | |
--humanize-style <style> | Style for this send when the humanizer runs: casual, professional, friendly. Honored whether or not --humanize is also set. | |
--humanize-preview <id> | Send exactly the rewrite molted humanizer preview showed (its previewId). The content must be what was previewed (409 otherwise); an id sends once; not with --no-humanize. | |
--reply-to <address> | Where replies go: one plain address (no display name, no list). Any domain; the email still comes from your mailbox. Omit it and replies come back to the mailbox. |
molted send batch
Send a batch of emails
molted send batch [options]| Option | Description | Default |
|---|---|---|
--template <slug> | Template slug (required) (required) | |
--recipients <json> | JSON array of {email, dedupeKey, payload, humanize, humanizeStyle, humanizePreviewId, replyTo} | |
--mailbox <id> | Mailbox ID | |
--stdin | Read recipients from stdin | |
--humanize | Batch default: ask for the humanizer. A recipient with its own "humanize" overrides this. Ignored if the account has it off. | |
--no-humanize | Batch default: send exactly as written, always honored. A recipient with its own "humanize" overrides this. | |
--humanize-style <style> | Batch default style: casual, professional, friendly. A recipient with its own "humanizeStyle" overrides this. | |
--reply-to <address> | Batch default. Where replies go: one plain address (no display name, no list). Any domain; the email still comes from your mailbox. Omit it and replies come back to the mailbox. A recipient with its own "replyTo" overrides this. |
Batch send delivers up to 500 emails in one API call. Each recipient goes through the full policy engine independently -- rate limits, suppressions, consent, cooldown -- so some may succeed while others are blocked.
Recipients format (JSON array)
[
{ "email": "alice@example.com", "dedupeKey": "promo-alice", "payload": { "name": "Alice" } },
{ "email": "bob@example.com", "dedupeKey": "promo-bob", "payload": { "name": "Bob" } }
]Required: "email". Optional: "dedupeKey" (recommended), "payload" (template variables),
"humanize" (boolean, overrides --humanize/--no-humanize for this recipient), "humanizeStyle"(overrides --humanize-style for this recipient), "humanizePreviewId" (send the rewrite that
"molted humanizer preview --template <slug> --payload <this recipient's payload>" showed;one preview per recipient, since a preview id sends once). A recipient whose preview doesn't fit gets its own error (humanize_preview_mismatch, humanize_preview_not_found, humanize_preview_used or humanize_preview_conflict); the rest of the batch still goes. "replyTo" (one plain address) overrides --reply-to for this recipient.
Examples
molted send batch --template promo \
--recipients '[{"email":"a@b.com","dedupeKey":"promo-a","payload":{"name":"A"}}]'
cat recipients.json | molted send batch --template welcome --stdin
molted send batch --template promo --no-humanize \
--recipients '[{"email":"a@b.com"},{"email":"b@b.com","humanize":true,"humanizeStyle":"casual"}]'
# a@b.com sends as written (the batch default); b@b.com asks for casual, honored if the account allows itResponse
{
"batchId": "<uuid>",
"total": 3,
"queued": 2,
"blocked": 1,
"scheduled": 0,
"errors": 0,
"results": [
{ "recipientEmail": "a@b.com", "status": "queued", "requestId": "<uuid>" },
{ "recipientEmail": "b@c.com", "status": "blocked", "requestId": "<uuid>", "reason": "suppressed" }
]
}Always check per-recipient status: "queued", "scheduled", "pending_approval",
"blocked" or "error" (a validation failure, not a policy block -- see theper-item "reason"). Note: the top-level queued/blocked/scheduled/errors counts don't include pending_approval items -- count those from "results" yourself (the CLI prints how many on stderr); they appear when the mailbox runs below autonomy level 3.
A batch can partially succeed; the CLI prints one guidance line per distinct block/hold reason on stderr and exits 1 only if nothing was accepted (queued, scheduled or pending_approval).
Pre-send checklist
- molted budget -- check remaining monthly/daily/hourly quota
- molted send simulate-batch -- dry-run policy check per recipient
- molted send batch -- send the batch
Limits: max 500 recipients per call. For larger lists, split into chunks.
Related: molted send simulate-batch, molted budget, molted templates
molted send simulate
Dry-run a send: the real send's checks, nothing delivered or recorded (accepts shorthand or template payload)
molted send simulate [options]| Option | Description | Default |
|---|---|---|
--to <email> | Recipient email address (required) (required) | |
--template <slug> | Template slug (e.g. welcome, _default) | |
--dedupe-key <key> | Deduplication key | |
--payload <json> | Template payload (JSON string) | |
--mailbox <id> | Mailbox ID | |
--thread-id <id> | Thread the send would reply in (replies skip the cooldown) | |
--subject <subject> | Email subject (shorthand: uses _default template) | |
--body <text> | Email body as plain text (shorthand: uses _default template) | |
--html <html> | Email body as HTML (shorthand: uses _default template) |
A dry run runs the same checks as a real send, in the same order, and changes nothing: no email, no send record, no quota or warmup counter used. It answers what the real send would answer:
wouldAllow: false blocked; "reason" is what the send would say,
"blockingReasons" lists every block foundwouldRequireApproval: true the send would wait for a human first
("approvalReason": autonomy_level,
thread_risk_flagged or content_check)
wouldDefer: true the send would be scheduled for a later warmup
slot ("scheduledAt")
rendered the subject/html/text the send would deliver
(an unsubscribe link is a placeholder)
Pass --thread-id for a reply in an existing thread: replies skip the cooldown, as they do for real.
Related: molted send, molted send simulate-batch, molted budget
molted send simulate-batch
Simulate a batch send
molted send simulate-batch [options]| Option | Description | Default |
|---|---|---|
--template <slug> | Template slug (required) (required) | |
--recipients <json> | JSON array of email strings or {email, ...} objects; only "email" is used (required) (required) |
Dry-run policy check for a batch of recipients. No emails are sent. Use this before "send batch" to see which recipients would be allowed or blocked by the policy engine.
Recipients can be simple email strings or full objects (so the same file works for "send batch"), but only each entry's "email" is checked: dedupeKey and payload are not sent, so a duplicate key is never reported here. Checks run against the workspace's default mailbox. Max 500 recipients per call.
molted send simulate-batch --template promo \
--recipients '["alice@example.com", "bob@example.com"]'
molted send simulate-batch --template promo \
--recipients '[{"email":"a@b.com","dedupeKey":"promo-a"}]'Response
{
"total": 2,
"wouldAllow": 1,
"wouldBlock": 1,
"wouldDefer": 0,
"results": [
{ "recipientEmail": "a@b.com", "wouldAllow": true },
{ "recipientEmail": "b@c.com", "wouldAllow": false, "reason": "suppressed" }
]
}Related: molted send batch, molted send simulate, molted budget
molted send propose
Propose an email (template candidates + policy pre-check)
molted send propose [options]| Option | Description | Default |
|---|---|---|
--to <email> | Recipient email address (required) (required) | |
--context <json> | Send intent as a JSON object (accepted, not read by the API today) |
Lists your active templates and runs a real send's policy rules for the recipient (suppression, pauses, billing, approval hold, limits, ...), without sending or writing anything. It does not write the email for you. The rules that need a template are not checked: policyPreCheck.notChecked names them.
--context: an optional JSON object describing the send intent (e.g. {"topic": "renewal", "name": "Alice"}). The API accepts any object but does not read it today, so it does not change the result.
The pre-check runs from the mailbox a real send by this key would use.
Example
molted send propose --to alice@example.com \
--context '{"topic": "renewal", "name": "Alice", "product": "Pro Plan"}'
# → returns: { "draftCandidates": [{ "templateId", "templateSlug", "templateName" }],
# "policyPreCheck": { "allow", "reason"?, "wouldRequireApproval",
# "approvalReason"?, "notChecked": [...] },
# "contactContext": {...} | null }After choosing a template, simulate (the full verdict) before sending
molted send simulate --to alice@example.com --template <templateId> \
--payload '<your payload JSON>'
molted send --to alice@example.com --template <templateId> \
--dedupe-key "propose-alice-1" --payload '<your payload JSON>'See CLI overview for global options, credentials and output format.