Guides

Sendara for AI agents

Give an LLM or a coding agent one way to send email: one endpoint, idempotent retries, typed errors, and a sandbox. Email is the only send channel that is generally available.

Why an agent can call Sendara

An autonomous agent calls an API with no person to check the call. Sendara has 4 properties that support this.

  • One endpoint. Every message goes through one POST /v1/send call. The agent learns 1 call, not a set of them. Email is the only channel that is generally available.
  • Idempotent by design. Each send carries an idempotency_key. A retry with the same key returns the original result instead of sending again. Agents that retry on timeout never double-send.
  • Predictable, typed JSON errors. Failures come back as { "error": { "code", "message", "status" } } with a stable machine-readable code. That makes it easy for an agent to branch on (from_not_verified, recipient_suppressed, …) without parsing prose.
  • Simple API-key auth. One header: Authorization: Bearer sk_live_…. No OAuth dance, no token refresh for the agent to manage.
  • Sandbox & test sends. A sk_test_… key simulates delivery (never billed, no real mail) so an agent can rehearse a workflow end to end before it touches a real inbox.
The whole API is one verb away: give the agent an API key and the send_email tool below, and it can send mail in a single tool call. Everything else (templates and domains) is additive. See the SDKs and API reference.

Give your agent the docs

We publish an LLM-readable dump of these docs at https://sendara.dev/llms.txt. It is a compact, plain-text description of the base URL, auth, the send body, current channel availability, error codes, and the sandbox. It is written to fit in a model's context window.

Two ways to use it:

  • Drop it in context.Fetch the file and paste it into the agent's system prompt or a project knowledge file (e.g. a CLAUDE.md or an OpenAI Assistant instruction). The model then knows how to call Sendara without you hand-writing the spec.
  • Index it for retrieval. Point a retrieval tool or vector store at the URL so the agent can pull the relevant section on demand. This is handy when you do not want the full file resident in every turn.
# Fetch the LLM-readable docs and hand them to your agent's context.
curl -s https://sendara.dev/llms.txt -o sendara-llms.txt
Whatever you put in the agent's context, make the sender rule explicit: when sending from your own domain it must set metadata.from_email (the SDK from) to an address on a verified domain, or the API returns 422 from_required. The value is a bare address or a display-name address with the name first: Acme <[email protected]> (the inverted <Acme> [email protected] is invalid). On a sandbox account with no verified domain, it should omit from_email and let the shared sender apply.

Minimal send

The simplest possible send, in every official SDK plus raw HTTP. This mirrors /docs/sending and /docs/sdks exactly. Construct a client with your API key, then call emails.send. The SDK attaches an idempotency_key for you when you do not pass one.

send.ts
import { Sendara } from "sendara";

const sendara = new Sendara(process.env.SENDARA_API_KEY!);

const { id } = await sendara.emails.send({
  from: "[email protected]",
  to: "[email protected]",
  subject: "Your receipt",
  html: "<h1>Thanks for your order</h1>",
});

console.log(id); // msg_a1b2c3
Python reserves from, so the helper takes from_ (keyword-only). In Go every call takes a context.Context first and params are passed by value. The SDK from maps to metadata.from_email. It is required when sending from your own verified domain (omit it only on a sandbox account, which uses the shared sender). Pass a display-name address with the name first to set a friendly name, for example from: "Acme <[email protected]>".

Tool / function calling

Expose Sendara to a model as a single send_email tool. The schema below is the Anthropic shape (name, description, input_schema). It maps directly to OpenAI function calling. Wrap it as { "type": "function", "function": { name, description, "parameters": input_schema } } and the JSON Schema body is identical. Either way the model returns the tool name and a JSON object of arguments.

send_email.tool.json
{
  "name": "send_email",
  "description": "Send a transactional email to a single recipient through Sendara. Returns the created message id.",
  "input_schema": {
    "type": "object",
    "properties": {
      "to": {
        "type": "string",
        "description": "Recipient email address."
      },
      "subject": {
        "type": "string",
        "description": "Subject line."
      },
      "html": {
        "type": "string",
        "description": "HTML body of the email."
      },
      "from": {
        "type": "string",
        "description": "Sender on a verified domain. Required when sending from your own domain. Maps to metadata.from_email. A bare address ([email protected]) or a display-name address with the name first (Acme <[email protected]>). Omit only on a sandbox account with no verified domain, which uses the shared sender."
      }
    },
    "required": ["to", "subject", "html"]
  }
}

When the model emits a tool call, route its name and arguments into a handler that calls the Sendara SDK and returns the result for the model to read on the next turn.

handler.ts
import { Sendara } from "sendara";

const sendara = new Sendara(process.env.SENDARA_API_KEY!);

// Anthropic gives you tool_use blocks. OpenAI gives you tool_calls.
// Both hand you a name + a JSON object of arguments. Route them here.
export async function handleToolCall(name: string, args: Record<string, unknown>) {
  if (name !== "send_email") throw new Error(`unknown tool: ${name}`);

  const { id } = await sendara.emails.send({
    from: (args.from as string) ?? "[email protected]",
    to: args.to as string,
    subject: args.subject as string,
    html: args.html as string,
    // Stable key so a retried tool call never sends twice (see below).
    idempotencyKey: `tool-${args.to}-${args.subject}`,
  });

  // Feed this back to the model as the tool result.
  return { message_id: id };
}
Keep the tool surface tiny. Most agents only need send_email. Add a send_email_template tool (passing templateId + templateVars) only when you want copy to live in Sendara instead of the model's output. See templates.

Idempotency & retries

Autonomous agents retry after timeouts, tool-runner restarts, and replanned steps. Without a stable key, each retry is a fresh send and your user gets duplicate mail. Pass a deterministic idempotency_key derived from the logical action (an order id, a recipient + intent) so a retried call is a guaranteed no-op.

idempotent.ts
// Derive the key from the action, not from the clock or a random value.
// The same logical send must produce the same key on every retry.
await sendara.emails.send({
  from: "[email protected]",
  to: "[email protected]",
  subject: "Your receipt",
  html: "<p>Thanks!</p>",
  idempotencyKey: `receipt-${orderId}`, // same order ⇒ at most one email
});
Reusing a key with a different payload returns 409. Keys are bound to the request they first succeeded with. A clean retry of the same send returns the original result. More on the SDK retry behavior (429, 5xx, network) in the SDKs guide.

Dry-run safely

Let an agent rehearse without billing or real mail. Hand it a test key (sk_test_…) and every send is simulated. It is not billed, no real delivery happens, and your webhooks still fire, so the full workflow runs end to end. Address the simulator inbox to force an outcome: delivered@, bounced@, or complained@ on any domain.

sandbox.ts
const sandbox = new Sendara(process.env.SENDARA_TEST_KEY!); // sk_test_…

await sandbox.emails.send({
  from: "[email protected]",
  to: "[email protected]", // emits synthetic sent, then bounced webhooks
  subject: "Agent dry run",
  html: "<p>Not really sent.</p>",
});

To have the agent send a real email to one of your own verified test recipients (free, capped per day), set testSend. The SDK forwards it as test_send: true:

test-send.ts
await sendara.emails.send({
  from: "[email protected]",
  to: "[email protected]", // must be a verified test recipient
  subject: "Agent UAT real delivery",
  html: "<p>This one actually arrives.</p>",
  testSend: true,
});
A test-send guard returns recipient_not_verified with status 403, or test_send_daily_limit with status 429. Branch on code. See sandbox & test sends for the full flow.

Know what you can send

Sendara gives you two endpoints that hold an autonomous agent inside safe bounds. The first reports what the account can send. The second caps what the account can spend.

GET /v1/account/verification tells the agent whether it is in sandbox mode and which channels are verified. In sandbox_mode the account has no fully-verified sending domain, so real mail only goes from the shared shared_email_senderto the account's own address. Have the agent read this first and refuse (or fall back to the shared sender) rather than attempt a send that would 422. Any valid key can read it.

verification.sh
curl https://api.sendara.dev/v1/account/verification \
  -H "Authorization: Bearer sk_live_xxx"
{
  "account_id": "acc_8c2d4e6f",
  "channels": [
    { "channel": "email",   "status": "sandbox",        "verified": false },
    { "channel": "sms",     "status": "not_configured", "verified": false },
    { "channel": "webhook", "status": "ready",          "verified": true }
  ],
  "sandbox_mode": true,
  "shared_email_sender": "[email protected]"
}

PUT /v1/spend-caps sets a hard spend ceiling so a looping or misbehaving agent can never run up an unbounded bill. Limits are in micro-dollars (1_000_000 = $1). Omit key_id for an account-wide cap, or pass one to cap a single key. Once the hard limit is reached, further sends are rejected instead of billed. This call needs an admin key.

spend-cap.sh
# Hard-cap this account at $50 of spend.
curl -X PUT https://api.sendara.dev/v1/spend-caps \
  -H "Authorization: Bearer sk_live_admin_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "hard_limit_micros": 50000000 }'
{
  "id": "cap_5b2e9a71c0d34f68",
  "account_id": "acc_8c2d4e6f",
  "key_id": null,
  "soft_limit_micros": null,
  "hard_limit_micros": 50000000
}
Set the cap once from your control plane with an admin key, then hand the agent a narrower send key. The ceiling holds no matter what the agent does.

Migrate an app from another provider

Swapping the SDK, renaming the env var, reshaping the send payload, and remapping webhook event names is exactly the rote work an agent does well. Hand it the llms.txt mapping plus a per-provider migration guide and it can do the mechanical edits. You keep the deliverability decisions (verify the same domain in both, parallel-run, then cut over). The send call is the same shape on both sides: sendara.emails.send({ from, to, subject, html, text }), where from travels as metadata.from_email.

ProviderSDK packageEnv varSend callGuide
ResendresendRESEND_API_KEYresend.emails.sendResend
SendGrid@sendgrid/mailSENDGRID_API_KEYsgMail.sendSendGrid
PostmarkpostmarkPOSTMARK_SERVER_TOKENclient.sendEmailPostmark
Mailgunmailgun.jsMAILGUN_API_KEYmg.messages.create(domain, …)Mailgun

Each guide carries the full concept map, before/after send code, the webhook event mapping, the suppression import, and a parallel-run cutover checklist. The condensed version is designed to drop straight into a model's context and lives in the "Migrating from another provider" section of llms.txt.

Paste this prompt into your coding agent to do the migration in your codebase. Replace {provider} with resend, sendgrid, postmark, or mailgun:

migrate.prompt.txt
Migrate my app from {provider} to Sendara (https://sendara.dev).

Read the condensed mapping and agent instructions in https://sendara.dev/llms.txt
(the "Migrating from another provider" section) and the human guide at
https://sendara.dev/docs/migrate/{provider}. Then, in this codebase:

1. Install the Sendara Node SDK (npm i sendara) and replace the {provider} client
   with: import { Sendara } from "sendara"; const sendara = new Sendara(process.env.SENDARA_API_KEY!).
   Rename the env var to SENDARA_API_KEY and update .env.example / config.
2. Rewrite every send call to sendara.emails.send({ from, to, subject, html, text }).
   Set "from" to an address on a verified domain (it travels as metadata.from_email);
   a bare address or a display-name address "Acme <[email protected]>" (name first).
3. Remap webhook/event handlers from {provider}'s event names to Sendara's
   (delivered, opened, clicked, delivery_delayed, bounced, complained, failed),
   subscribe with POST /v1/webhooks, and verify deliveries with the SDK's verifyWebhook().
4. Before the first real send, import my {provider} suppression list via
   POST /v1/suppressions/import { entries: [{ email, reason? }] } (admin-scoped key).
5. Use a test key (sk_test_…) to dry-run, then confirm each send in the Messages log
   with GET /v1/messages?idempotency_key=<key>.

Do not change the visible From domain. Keep {provider} verified and running in parallel;
do not delete its config until I confirm cutover.
Import your old provider's suppression list with POST /v1/suppressions/import before the first production send, so a fresh provider never re-mails an address the old one already learned to avoid. A send to a suppressed address is recorded as suppressed and is never enqueued or billed. See the migration guides for the safe parallel-run cutover.

The other channels

Email is the only send channel that is generally available. A feature flag holds the sms channel and the voice channel off. A call to POST /v1/send with channel set to sms or to voice returns 422 channel_not_enabled. Do not write an agent against either channel today. Read the sending guide and llms.txt for the channels that Sendara accepts.