API reference

Config, events, sending, templates, conversations, agents and adapters.

Entry points#

ImportExportsNeeds
@quami/sendocreateSendo, types, errors, metaDirect, agentTool, utilities, runDoctor / addAccountzod 4, ai 6 (peers)
@quami/sendo/adapters/supabasesupabaseAdapter@supabase/supabase-js
@quami/sendo/adapters/memorymemoryAdapter—
@quami/sendo/adapters/drizzledrizzleAdapter, DrizzleAdapterErrordrizzle-orm
@quami/sendo/adapters/prismaprismaAdapter, PrismaAdapterError@prisma/client
@quami/sendo/adapters/contractrunAdapterContract, runMediaStoreContractvitest
@quami/sendo/storage/supabasesupabaseStorage@supabase/supabase-js
@quami/sendo/storage/s3s3Storage, S3StorageError@aws-sdk/client-s3, @aws-sdk/s3-request-presigner
@quami/sendo/snippetsClient-safe snippet helpers: searchSnippets, findSnippetVariables, fillSnippetVariables, limits—
@quami/sendo/rulesClient-safe rule schemas: ruleParamsSchemas, ruleKeySchema, WEEKDAYS, parseRuleParams, rule typeszod 4
@quami/sendo/nexttoNextHandlersnext
npx @quami/sendodoctor, accounts add—

Everything runs on the server with the Supabase service role. Never import lib/sendo.ts from a client component. Client components may import values only from @quami/sendo/snippets and @quami/sendo/rules (import type from @quami/sendo is fine): the main entry pulls the server runtime (ai, node:crypto) into the browser bundle.

createSendo config#

lib/sendo.ts
import { createSendo } from "@quami/sendo";
import { supabaseAdapter } from "@quami/sendo/adapters/supabase";
import { createClient } from "@supabase/supabase-js";
import { z } from "zod";

function requireEnv(name: string): string {
  const value = process.env[name];
  if (value === undefined || value.trim() === "") {
    throw new Error(`[sendo] Missing environment variable ${name}. Add it to .env.local.`);
  }
  return value;
}

// Service-role client: server only, never import this file from client components.
const supabaseAdmin = createClient(
  requireEnv("NEXT_PUBLIC_SUPABASE_URL"),
  requireEnv("SUPABASE_SERVICE_ROLE_KEY"),
  { auth: { persistSession: false, autoRefreshToken: false } },
);

export const sendo = createSendo({
  adapter: supabaseAdapter(supabaseAdmin, {
    storageBucket: process.env.SENDO_STORAGE_BUCKET ?? "sendo-media",
  }),
  appSecret: requireEnv("SENDO_META_APP_SECRET"),
  verifyToken: requireEnv("SENDO_VERIFY_TOKEN"),
  // sendo_accounts.token_ref holds the NAME of the env var with the token (e.g. SENDO_TOKEN_MAIN).
  resolveToken: (account) => process.env[account.tokenRef],
  // findPatientIdByPhone / listAdminIds stand for your own queries.
  // Your entity id for this phone number (patient, resident, user), or null.
  linkContact: async ({ phone, account }) => findPatientIdByPhone(phone, account.tenantId),
  // Who gets a handed-off conversation. Exactly one id auto-assigns it.
  resolveHandoffTargets: async ({ account }) => listAdminIds(account.tenantId),
  agentDefaults: { historyLimit: 20, maxStepsPerTurn: 5, maxMessagesPerMinutePerContact: 10 },
  eventRetentionDays: 30,
  logger: console,
});
OptionTypeDefault / notes
adapterSendoAdapterRequired. supabaseAdapter or memoryAdapter.
mediaMediaStoreWhere media bytes live (MediaStore). Default: the adapter's storage.
appSecretstringRequired unless transport or connect is set. Verifies X-Hub-Signature-256.
verifyTokenstringRequired unless transport is set. Webhook GET challenge.
resolveToken(account) => string | null | undefined | Promise<…>Required unless transport is set. Called per Meta call; empty → ConfigError.
transportTransportReplaces the three options above (metaDirect(...) or your own Transport).
graphVersionstringGRAPH_API_VERSION ("v26.0"). Shorthand config only.
fetchtypeof fetchglobalThis.fetch. Shorthand config only.
linkContact({ phone, account, contact }) => string | nullCalled on a new contact and when the profile name changes; stored in contact.appRef.
resolveHandoffTargets({ to, account, conversation, contact }) => string[]Handoff assignees. Exactly one id sets assignedTo.
agentDefaults{ historyLimit, maxStepsPerTurn, maxMessagesPerMinutePerContact }20, 5, 10
eventRetentionDaysnumber30 (sendo.maintenance.prune)
locale"en" | "es""en". Language of the built-in handoff and opt-out messages (BUILT_IN_MESSAGES[locale]).
handoffMessagestring | nullBUILT_IN_MESSAGES[locale].handoff ("Connecting you with someone from the team."); null: none.
defaultCountrystringISO-2 or calling code for national numbers, e.g. "GT".
loggerLoggerConsole-like. Every line is redacted (tokens, secrets, Authorization).
storeToken({ account, token }) => Promise<string>Embedded Signup token storage; returns the tokenRef.
deleteToken(tokenRef) => Promise<void>Called by sendo.accounts.disconnect.
meta{ appId?, configId? }Your Meta app, for Embedded Signup.
coexistence{ pauseAgentOnPhoneReply?, importHistory? }true, false. Numbers shared with the WhatsApp Business app.
connect{ apiKey, signingSecret, baseUrl? }Sendo Connect: relay signature and signup (Sendo Connect).
optOut{ keywords?, confirmation? }["BAJA","STOP","ALTO","CANCELAR"], BUILT_IN_MESSAGES[locale].optOutConfirmation; null: none.
transcribe{ model, timeoutMs? }AI SDK transcription model for inbound voice notes (Voice notes).
resolveRole({ userId, tenantId }) => { role, queues? } | nullRequired for sendo.actor and every { actor } API; null means no access.
resolveUsers(ids) => { id, name, avatarUrl? }[]Names and avatars in the inbox and metrics.
resolveAssignees({ tenantId, queue, account }) => string[]Assign menu and auto_assign rules. Falls back to resolveHandoffTargets.
resolveSnippetVariables({ contact, conversation, account }) => Record<string, string>App variables for snippets.render (e.g. saldo).

createSendo throws ConfigError on invalid config. On first use it checks sendo_schema_meta and logs a warning when the tables are missing or older than SENDO_SCHEMA_VERSION.

Events (sendo.on)#

sendo.on(event, handler) returns an unsubscribe function. Handlers run one after another in the background pipeline; a throwing handler is logged and the pipeline goes on.

EventPayloadWhen
message{ message, contact, conversation, account, reply }Each new inbound message, after it (and its media) is stored. message.media is the Media row or null.
status{ message, status, error? }A delivery status moved an outbound message forward (sent → delivered → read, or failed).
handoff{ conversation, contact, account, reason, assignees }A conversation was handed to humans.
conversation.status{ conversation, from, to, by }agent / human / closed changes, including a reopen (by "system").
account.error{ account, error }Meta rejected the account token (error.isInvalidToken, code 190).
account.default{ tenantId, account, previous }A tenant's default number changed (account is null when it has none).
account.disconnected{ account, by }sendo.accounts.disconnect.
account.billing{ account, billingReady, previous }A number's payment-method state changed.
event.unrouted{ phoneNumberId, raw }A webhook for a phone_number_id with no active sendo_accounts row.
contact.opt_in{ contact, optIn, previous, source, keyword, messageId }sendo.contacts.setOptIn or an inbound opt-out keyword changed a contact's marketing opt-in.
ts
const unsubscribe = sendo.on("message", async ({ message, contact, conversation, reply }) => {
  if (message.type === "text" && message.text?.trim().toLowerCase() === "menu") {
    await reply.buttons({
      body: `Hola ${contact.profileName ?? ""}, ¿qué necesitas?`,
      buttons: [
        { id: "cita", title: "Agendar cita" },
        { id: "humano", title: "Hablar con alguien" },
      ],
    });
  }
  if (message.media?.downloadStatus === "done") {
    console.log("stored at", message.media.storagePath, "in", conversation.id);
  }
});

sendo.on("status", ({ message, status, error }) => {
  if (status === "failed") console.warn("send failed", message.id, error?.code, error?.message);
});

sendo.on("handoff", async ({ conversation, assignees, reason }) => {
  await notifyStaff(assignees, `Conversación ${conversation.id}: ${reason}`);
});

sendo.on("conversation.status", ({ conversation, from, to, by }) => {
  console.log(conversation.id, from, "→", to, "by", by);
});

sendo.on("account.error", ({ account, error }) => {
  if (error.isInvalidToken) console.error(`Token ${account.tokenRef} expired or was revoked`);
});

sendo.on("event.unrouted", ({ phoneNumberId }) => {
  console.warn("Webhook for an unknown phone_number_id", phoneNumberId);
});

unsubscribe(); // stop listening

Sending (sendo.send)#

signatures
sendo.send.text(to, body, { account?, tenant?, previewUrl?, replyTo? }): Promise<Message>
sendo.send.media(to, { type, url? | file? | id?, mimeType?, caption?, filename? }, { account?, tenant?, replyTo? })
sendo.send.buttons(to, { body, buttons: { id, title }[] /* ≤ 3 */, header?, footer? }, { account?, tenant?, replyTo? })
sendo.send.list(to, { body, button, sections, header?, footer? }, { account?, tenant?, replyTo? })
sendo.send.template(to, name, { language, variables?, headerMedia?, buttons?, account?, tenant?, replyTo? })
sendo.send.markRead(messageId): Promise<void>

Every send:

  1. normalizes the number to E.164 (InvalidPhoneError);
  2. resolves the account: the account option, else the tenant's default (or only) number, else the only active account; AmbiguousAccountError when that is not one number (resolution order);
  3. gets or creates the contact and the conversation;
  4. checks the 24-hour window, except for templates (WindowClosedError, before any write or Meta call);
  5. inserts a sendo_messages row with status pending (sender system);
  6. calls Meta (file media is uploaded first);
  7. stores the Meta id and status sent, or failed with the error and rethrows.
ts
// Numbers are normalized to E.164: "+502 5555-1234" and "50255551234" both work.
const message = await sendo.send.text("+502 5555-1234", "Hola, tu cita está confirmada.", {
  previewUrl: false,
});
console.log(message.id, message.metaMessageId, message.status); // "sent"

await sendo.send.media("50255551234", { type: "image", url: "https://example.com/receta.png" });
await sendo.send.media("50255551234", { type: "document", file, filename: "factura.pdf" });

await sendo.send.buttons("50255551234", {
  body: "¿Confirmas tu cita de mañana a las 10:00?",
  buttons: [
    { id: "confirmar", title: "Confirmar" },
    { id: "reprogramar", title: "Reprogramar" },
  ],
  footer: "Clínica Central",
});

await sendo.send.list("50255551234", {
  body: "Elige un servicio",
  button: "Ver servicios",
  sections: [
    {
      title: "Consultas",
      rows: [
        { id: "general", title: "Medicina general" },
        { id: "pediatria", title: "Pediatría", description: "Lunes a viernes" },
      ],
    },
  ],
});

// Templates work outside the 24-hour window. Positional {{1}} or named {{nombre}} variables.
await sendo.send.template("50255551234", "recordatorio_cita", {
  language: "es",
  variables: ["Ana", "mañana 10:00"],
});

await sendo.send.markRead("wamid.HBgLNTAy..."); // sendo id or Meta id of an inbound message

reply#

The reply in the message event is bound to the conversation and stores sends with that sender: text(body, opts?), media(media, opts?), buttons(input, opts?), list(input, opts?), template(name, opts), react(emoji, messageId?) ("" removes the reaction) and markRead(messageId?). Both default to the inbound message.

sendo.templates#

ts
// Pull approved templates from Meta into sendo_templates (run after creating or editing one).
await sendo.templates.sync(); // every active account, or { account }
const templates = await sendo.templates.list({ status: "APPROVED" });
console.log(templates.map((t) => `${t.name}/${t.language} (${t.variableCount} vars)`));
  • sync({ account?, tenant? }) pulls templates from Meta (one account, the tenant's active accounts, or every active account), upserts them and marks the ones deleted on Meta inactive. Returns Template[].
  • list({ account?, tenant?, status?, includeInactive? }) reads sendo_templates.

sendo.accounts#

list, get, add, setDefault, setLabel, pauseAgent, setAgentMode, disconnect, purge, refreshBilling, completeEmbeddedSignup and export. See Numbers and tenants.

  • disconnect(id) of a number onboarded through Sendo Connect also calls Connect's POST /v1/numbers/{phoneNumberId}/disconnect (best effort: a failure is a warning, never an error). See Disconnecting a number.
  • export(id, { actor }) → AsyncIterable<string>: the chunks of one SendoExportDocumentV1 JSON document (the number's contacts, tags, conversations, messages, notes, system events and media references), streamed and a snapshot as of exportedAt. Admins only: any other actor gets ForbiddenError on the call, before any chunk. exportFileName(displayPhone, exportedAt) names the download. See Exporting a number's history.

sendo.conversations#

ts
const conversation = await sendo.conversations.get(conversationId); // null if unknown
const { open, expiresAt } = await sendo.conversations.windowStatus(conversationId);

await sendo.conversations.handoff(conversationId, { reason: "Pide hablar con una persona" });
await sendo.conversations.assign(conversationId, userId, { by: userId }); // also sets "human"
await sendo.conversations.returnToAgent(conversationId, { by: userId });
await sendo.conversations.close(conversationId, { by: userId });
// No agent turns on this conversation; on resume it doesn't answer what arrived meanwhile.
await sendo.conversations.pauseAgent(conversationId, true, { by: userId });
  • handoff(id, { reason, to?, message?, by? }): status human, reason stored, resolveHandoffTargets called (one assignee → assignedTo), the handoff message sent, handoff event emitted.
  • returnToAgent(id) clears assignedTo and handoffReason; close(id) closes it. A new inbound message reopens a closed conversation with status agent.
  • assign(id, userId) also moves the conversation to human; assign(id, null) only unassigns.
  • pauseAgent(id, paused, { by? }) stops (or resumes) the agent on one conversation; on resume it doesn't answer what arrived during the pause. Emits conversation.agent_paused.
  • by (default "system") shows up in the conversation.status, conversation.assigned and conversation.agent_paused events and in the thread's system line.

sendo.inbox#

Server-side operations behind the inbox server actions. Every method takes { actor } from sendo.actor(userId, tenantId) and enforces the role matrix (Team inbox): another tenant's conversation is RecordNotFoundError, a conversation or action the actor isn't allowed is ForbiddenError. A missing or forged actor (no user id, unknown or null role) is ForbiddenError with role: null in every { actor } API, and an accountId filter that isn't a number of the actor's tenant is AccountNotFoundError.

list and count leave out the conversations of disconnected numbers unless the query has includeInactiveAccounts: true, that number's accountId or a non-blank search.

MethodReturnsNotes
list({ actor, status?, mine?, assignedTo?, queue?, tagIds?, accountId?, search?, cursor?, limit? })Page<InboxConversation>Agents only get what they may see. Most recent activity first. Limit 30, max 100. count(query) counts them.
get(id, { actor })InboxConversationOne conversation.
thread(id, { actor, cursor?, limit? })Page<InboxMessage>Newest first, notes, system lines and drafts included. Media URLs signed for 300 s. Limit 50, max 100.
reply(id, payload, { actor })InboxMessageStep-in: sender human, status unchanged, pending draft discarded. WindowClosedError outside the window.
note(id, text, { actor })InboxMessageInternal note, never sent.
take / returnToAgent / close(id, { actor })InboxConversationTake assigns the actor and moves to human.
assign(id, userId | null, { actor })InboxConversationAnyone but the actor needs conversations.reassign.
pauseAgent(id, paused, { actor })InboxConversationNeeds conversations.pause_agent.
approveDraft(messageId, { actor, text? }) / discardDraft(messageId, { actor })InboxMessage / voidWindowClosedError when the window closed.
markSeen(id, { actor })voidResets unreadCount.
assignees(id, { actor })InboxAssignee[]From resolveAssignees and resolveUsers.
tags.add / tags.remove(id, tagId, { actor })InboxTag[]The conversation's tags afterwards.
cost(id, { actor })InboxConversationCost{ turns, tokensIn, tokensOut, costCents, unpricedTurns } over every agent turn, drafts included. Needs costs.view.

InboxConversation adds window, contact.displayName, lastMessagePreview, channel, source, queue, assignee, handoff { reason, by, at }, agentPaused / agentPausedOn, hasPendingDraft, accountActive and tags. InboxMessage adds internal, systemLine ({ kind, event, by, params }, see System events), draft, handoffAfterDraft, approvedBy, edited, origin, source, transcription ({ model, ms, error } for transcribed or failed voice notes, else null), and tokensIn / tokensOut / costCents (admins only). A disconnected number makes reply, note and approveDraft throw AccountStateError (ACCOUNT_STATE).

Every method takes { actor } and is scoped to the actor's tenant.

MethodReturnsNotes
snippets.list({ actor, accountId? })Snippet[]Team snippets plus the actor's personal ones; with accountId, only those usable on that number.
snippets.get(id, { actor })SnippetSomeone else's personal snippet is RecordNotFoundError.
snippets.upsert({ id?, shortcut, body, visibility?, accountId? }, { actor })SnippetTeam snippets need snippets.manage_tenant. Taken shortcut: ConstraintViolationError.
snippets.delete(id, { actor })void
snippets.render(snippetId, { actor, conversationId }){ text, unresolved }Fills {{nombre}}, {{telefono}}, {{agente}} and resolveSnippetVariables; the rest stays as is.
tags.list({ actor })Tag[]Every role.
tags.upsert({ id?, name, color }, { actor }) / tags.delete(id, { actor }) / tags.usage(id, { actor })Tag / void / numberNeed tags.manage. Deleting removes the tag from every conversation.
links.create({ account?, tenant?, text?, source }){ url, code, phone, text }A wa.me link whose text ends in [#source] (attribution). No actor needed.
links.sources({ actor, account?, from?, to? }){ source, conversations }[]Conversations per source; needs numbers.manage.

See Snippets and tags and Widgets.

Broadcasts and opt-in#

sendo.broadcasts sends one APPROVED template to many contacts. Every method takes { actor } and needs broadcasts.manage (admins); another tenant's broadcast is RecordNotFoundError.

  • preview({ actor, template, language, recipients, variables?, account?, ratePerMinute? }) returns { total, eligible, skipped: { no_opt_in, opted_out, invalid_phone }, category, ratePerMinute, estimatedMinutes } and writes nothing.
  • create(sameInput) stores a queued broadcast. Recipients are contact ids, { phone } (upserted as a contact) or { contactId | phone, variables }. MARKETING templates go only to contacts with marketingOptIn = true; UTILITY and AUTHENTICATION go to everyone who hasn't explicitly opted out. Ineligible recipients are stored skipped with the reason. ratePerMinute defaults to 60.
  • get(id) adds counts per recipient status, recipients(id, { status?, cursor?, limit? }) pages the per-recipient results, list({ cursor?, limit? }) is newest first, and cancel(id) turns pending recipients into cancelled.
  • sendo.maintenance.tick() sends up to ratePerMinute recipients per broadcast and minute. Status webhooks update each recipient. Meta 131042 pauses the broadcast (pauseReason: "billing", number billingReady = false) until billing is ready again; a rate limit (130429) leaves recipients pending for the next tick. Broadcast messages never move a conversation to human or run the agent; replies land in the normal conversation.
  • sendo.contacts.setOptIn(contactId, optIn, { source? }) records an explicit decision (marketingOptIn, optInAt = now) and emits contact.opt_in. An inbound message that is exactly an optOut keyword (trimmed, case and accents ignored) opts the contact out, sends the confirmation inside the 24-hour window and skips the agent turn.

sendo.metrics#

Read-only reports for supervisors and admins. Every method takes { actor, from, to, account?, tagIds? } (to is exclusive; account is an account id or Meta phone number id of the actor's tenant; tagIds cuts by any matching tag) and needs metrics.view. Agents get ForbiddenError.

MethodReturnsNotes
metrics.daily(query)MetricsDaily[]Per account and local day: inbound, outbound, conversations started and closed. Cost and token fields are null without costs.view.
metrics.byAssignee(query)MetricsAssignee[]Conversations, messages sent and median first response per person, with name / avatarUrl from resolveUsers.
metrics.firstResponse(query)MetricsFirstResponseRow[]One row per conversation. Notes, drafts and imported messages don't count.
metrics.aiResolution(query)MetricsAiResolutionRow[]Closed conversations with no handoff and no human message.
metrics.bySource(query)MetricsSourceRow[]Conversations per source ([#code], ad:<id>, null for direct) and day.
metrics.cost(query)MetricsCostRow[]Cost and tokens per AI agent and day. Needs costs.view (admins).

metricsDayRange({ from, to, timeZone }) turns local dates into the from / to instants. Drizzle and Prisma apps can run the same queries with METRICS_SQL and metricsSqlParams (Metrics).

sendo.events and sendo.maintenance#

Every webhook POST is stored in sendo_events before the 200 and ends as processed, failed (with a redacted error) or unrouted.

ts
const event = await sendo.events.get(eventId); // raw payload + status
if (event?.status === "failed" || event?.status === "unrouted") {
  const after = await sendo.events.reprocess(eventId); // idempotent: messages are deduped
  console.log(after.status, after.error);
}
ts
const deleted = await sendo.maintenance.prune(); // eventRetentionDays (default 30)
await sendo.maintenance.prune({ olderThanDays: 7 });

Scheduling the prune: Retention.

sendo.handleWebhook#

sendo.handleWebhook(request, { waitUntil? }) is the framework-agnostic entry point (Web Request → Response):

RequestResponse
GET with the matching hub.verify_token200 with hub.challenge (text/plain)
GET with a wrong or missing token403
POST with a missing or invalid X-Hub-Signature-256401, nothing stored
With connect: POST with a missing or invalid X-Sendo-Signature401, nothing stored
With connect: relay-signed {"object":"sendo_test"}200 with Cache-Control: no-store, nothing stored or emitted (test event)
POST with a body that isn't JSON400
POST, event could not be stored500 (Meta retries)
POST, event stored200 immediately; processing continues via waitUntil
Any other method405
ts
// Any runtime with Web Request/Response (Hono, Supabase Edge Functions, Workers...).
const worker = {
  fetch(request: Request, _env: unknown, ctx: { waitUntil(p: Promise<unknown>): void }) {
    // GET: verify challenge. POST: signature check, store event, 200, then process in
    // the background through waitUntil (without it the work runs as a detached promise).
    return sendo.handleWebhook(request, { waitUntil: (p) => ctx.waitUntil(p) });
  },
};

export default worker;

@quami/sendo/next#

toNextHandlers(sendo, { waitUntil? }) returns { GET, POST } for an App Router route. POST keeps the pipeline alive with after() from next/server (loaded lazily); pass waitUntil to override it.

app/api/sendo/webhook/route.ts
// app/api/sendo/webhook/route.ts (installed by @sendo/webhook)
import { toNextHandlers } from "@quami/sendo/next";
import { sendo } from "@/lib/sendo";

export const { GET, POST } = toNextHandlers(sendo); // POST uses next/server `after()`
export const runtime = "nodejs";
export const maxDuration = 60;

Agents (sendo.agent)#

sendo.agent(definition) registers an agent and returns { name, remove() }. It answers inbound messages in conversations whose status is agent on the accounts it matches. Only one agent may match an account.

FieldNotes
nameUnique; stored as senderRef of the agent's messages.
accounts?Account ids / phone number ids, or (account) => boolean. Default: all.
tenants?Tenant ids (null = no tenant) or (tenantId) => boolean. ANDed with accounts. Default: all. See one agent for many tenants.
mode?"auto" or "draft"; the number's agentMode wins. Default "auto".
modelGateway id string ("openai/gpt-5-mini") or an AI SDK LanguageModel.
instructionsstring or ({ account, contact, conversation, sendo }) => string | Promise<string>.
tools?{ [name]: { description?, input: zod schema, run(ctx) } }. Use agentTool() for typed input.
handoff?{ when, to, message? } adds the transferir_a_humano({ motivo }) tool.
historyLimit? / maxStepsPerTurn? / maxMessagesPerMinutePerContact?Default agentDefaults (20 / 5 / 10).
fallbackMessage?Sent when the model or a turn fails. Default: nothing.
vision?Default true: images go to the model as image parts; false describes them in text.
timezone?IANA zone for the date in the system prompt. Default: the account's.
pricing?{ inputPerMTok, outputPerMTok } in USD, for costCents when the provider reports no cost. See usage and cost.
ts
const agendarCita = agentTool({
  description: "Agenda una cita para el paciente vinculado a esta conversación",
  input: z.object({ fecha: z.iso.date(), hora: z.string().regex(/^\d{2}:\d{2}$/) }),
  // `input` is already validated (and typed) by the zod schema.
  async run({ input, contact, account }) {
    // Authorize inside the tool: only act on the entity linked to THIS contact.
    if (contact.appRef === null) {
      return { error: "Paciente no identificado. Pide su número de DPI." };
    }
    return bookAppointment({
      tenantId: account.tenantId,
      patientId: contact.appRef,
      date: input.fecha,
      time: input.hora,
    });
  },
});
ts
const handle = sendo.agent({
  name: "recepcion",
  // Only numbers of tenants with the feature on (or a list of account / phone number ids).
  accounts: (account) => account.tenantId !== null,
  model: "openai/gpt-5-mini",
  instructions: async ({ contact, account }) =>
    `Eres la recepción de ${await clinicName(account.tenantId)}. ` +
    `Atiende a ${contact.profileName ?? "el paciente"} en español, en mensajes breves.`,
  tools: { agendar_cita: agendarCita },
  handoff: {
    when: "pide hablar con una persona, tiene una urgencia o está molesto",
    to: "recepcion",
    message: "Te comunico con una persona del equipo.", // null sends nothing
  },
  historyLimit: 20,
  maxStepsPerTurn: 5,
  maxMessagesPerMinutePerContact: 10,
  fallbackMessage: null,
  vision: true,
  timezone: "America/Guatemala",
});

console.log(handle.name, HANDOFF_TOOL_NAME); // "recepcion", "transferir_a_humano"
handle.remove(); // unregister

One turn, per conversation:

  1. Takes the conv:<id> lock (60 s); if another turn holds it, that turn picks up the new message. With the lock held, skips the turn when the conversation or its number is paused (agentPaused) or a rule holds the agent; the message is still stored. A pause set during a turn lets that turn finish.
  2. Skips the turn when the contact sent more than maxMessagesPerMinutePerContact messages in the last minute (messages are still stored).
  3. Builds the system prompt in English (instructions, handoff clause, an instruction to reply in the contact's language, contact name / phone / appRef, current date and time) and the last historyLimit messages of this conversation only.
  4. Runs generateText with stopWhen: stepCountIs(maxStepsPerTurn). Tool input is validated with zod before run; each tool receives { input, contact, conversation, account, sendo }.
  5. Sends the final text as sender agent (or stores it as a draft in draft mode) with the turn's tokens and cost; tool calls and results go to sendo_messages.metadata, never to the user. Notes, system lines and drafts are never part of the model's history.
  6. Loops while new inbound messages arrived (at most 3 turns), then releases the lock. On errors: logs, sends fallbackMessage if set, never leaves the lock held.

When the model calls transferir_a_humano, Sendo runs sendo.conversations.handoff (by agent:<name>) and the agent stays silent until the conversation returns to agent.

Adapters#

All storage goes through a SendoAdapter. Two ship with the package; both pass the same contract suite.

supabaseAdapter#

ts
import { supabaseAdapter } from "@quami/sendo/adapters/supabase";
import { createClient } from "@supabase/supabase-js";

// The SERVICE ROLE client (bypasses RLS). Server only.
const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
  process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
  { auth: { persistSession: false, autoRefreshToken: false } },
);

export const adapter = supabaseAdapter(supabaseAdmin, {
  storageBucket: "sendo-media", // private bucket
  signedUrlTtlSec: 300,
});

Uses the service-role client, the sendo_* tables and RPCs from the migration, and supabaseStorage on the given storageBucket as its default media store (createSendo({ media }) overrides it). Database errors surface as SupabaseAdapterError (code, operation, details, hint).

memoryAdapter#

ts
import { createSendo } from "@quami/sendo";
import { memoryAdapter } from "@quami/sendo/adapters/memory";

// In-memory storage for unit tests and quick experiments. Nothing is persisted.
const adapter = memoryAdapter({ now: () => new Date("2026-10-06T12:00:00Z") });

const sendo = createSendo({
  adapter,
  appSecret: "test-secret",
  verifyToken: "test-verify",
  resolveToken: () => "test-token",
});

await adapter.accounts.insert({
  phoneNumberId: "100000000000001",
  wabaId: "200000000000001",
  displayPhone: "+1 555 000 1111",
  tokenRef: "SENDO_TOKEN_MAIN",
});
adapter.reset(); // clears every table between tests

Options: now (clock), signedUrlBase, and schemaVersion / missingTables / bucketExists (what storage.exists() returns) to simulate broken setups. Use it in unit tests with a mocked fetch for Meta.

runAdapterContract#

A Vitest suite every adapter must pass (accounts, contacts, conversations, messages including the forward-only status rule and the status race, media, templates, events, locks, storage, schema version). Write your own adapter and run:

my-adapter.contract.test.ts
// my-adapter.contract.test.ts (run with Vitest)
import { runAdapterContract } from "@quami/sendo/adapters/contract";
import type { SendoAdapter } from "@quami/sendo";

declare function createMyAdapter(): Promise<SendoAdapter>;
declare function truncateTables(): Promise<void>;
declare const databaseReachable: boolean;

runAdapterContract("my-adapter", () => createMyAdapter(), {
  skip: databaseReachable ? false : "database unreachable",
  afterEach: truncateTables,
});

Media storage (MediaStore)#

Inbound media (images, voice notes, documents) is downloaded from Meta and stored through a MediaStore. The same store signs the URLs sendo.inbox.thread returns (300 s), feeds images to agents with vision, deletes the objects on sendo.accounts.purge, and is what sendo doctor checks.

ts
interface MediaStore {
  put(path: string, bytes: Uint8Array, mimeType: string): Promise<void>;
  get(path: string): Promise<Uint8Array | null>; // null when missing
  signedUrl(path: string, ttlSeconds: number): Promise<string>;
  delete(paths: string[]): Promise<void>; // missing paths are ignored
  exists?(): Promise<boolean>; // doctor's bucket check
}

Which store is used: createSendo({ media }) if set, else the adapter's default storage (supabaseAdapter → supabaseStorage on its storageBucket; memoryAdapter → an in-memory map). With neither, inbound media is stored as failed, an error is logged and doctor fails with a config fix.

  • supabaseStorage({ client, bucket, signedUrlTtlSec? }) from @quami/sendo/storage/supabase: a private Supabase Storage bucket with the service-role client. It's what supabaseAdapter uses, so you only need it to pair Supabase Storage with another adapter.
  • s3Storage({ client, bucket, signedUrlTtlSec? }) from @quami/sendo/storage/s3: AWS S3, Cloudflare R2, MinIO and other S3-compatible stores. Install the optional peers @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner. Signed URLs are SigV4 presigned GetObject URLs (TTL capped at 7 days); deletes are batched 1000 keys per DeleteObjects. Failures are S3StorageError (operation, code, status). The credentials need s3:PutObject, s3:GetObject, s3:DeleteObject and, for doctor, s3:ListBucket (HeadBucket).
lib/sendo.ts
import { S3Client } from "@aws-sdk/client-s3";
import { s3Storage } from "@quami/sendo/storage/s3";

export const media = s3Storage({
  client: new S3Client({
    region: "auto",
    endpoint: process.env.S3_ENDPOINT, // e.g. https://<account>.r2.cloudflarestorage.com; omit for AWS
    credentials: {
      accessKeyId: process.env.S3_ACCESS_KEY_ID!,
      secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
    },
  }),
  bucket: process.env.S3_BUCKET ?? "sendo-media",
});

export const sendo = createSendo({ adapter, media /* , ... */ });

Keep the bucket private: the inbox only ever shows signed URLs. Some S3-compatible stores reject the CRC32 checksums recent AWS SDK versions send by default; if uploads fail with a checksum error, pass requestChecksumCalculation: "WHEN_REQUIRED" to the S3Client.

For sendo doctor --config <module> and sendo mcp, export media next to adapter from the module so doctor checks that bucket. A custom store should pass runMediaStoreContract(name, () => store) from @quami/sendo/adapters/contract.

Transport#

Graph API calls go through a Transport. The config shorthand builds metaDirect for you; build it yourself to change the Graph version or fetch:

ts
import { GRAPH_API_VERSION, createSendo, metaDirect } from "@quami/sendo";
import { memoryAdapter } from "@quami/sendo/adapters/memory";

// Same as the appSecret / verifyToken / resolveToken shorthand, plus transport options.
const transport = metaDirect({
  appSecret: process.env.SENDO_META_APP_SECRET ?? "",
  verifyToken: process.env.SENDO_VERIFY_TOKEN ?? "",
  resolveToken: (account) => process.env[account.tokenRef],
  version: GRAPH_API_VERSION, // pinned Graph API version, e.g. "v26.0"
});

export const sendo = createSendo({ adapter: memoryAdapter(), transport });

Any object implementing the Transport interface (send, fetchMedia, optional uploadMedia, markRead, handleWebhook, subscribe) can be passed as transport:

ts
import type { Account, FetchedMedia, MessagePayload, Transport } from "@quami/sendo";

// The interface metaDirect implements. Pass any object implementing it as `transport`.
export class GatewayTransport implements Transport {
  readonly kind = "gateway";

  async send(account: Account, payload: MessagePayload): Promise<{ metaMessageId: string }> {
    return gateway("send", { phoneNumberId: account.phoneNumberId, payload });
  }

  async fetchMedia(account: Account, mediaId: string): Promise<FetchedMedia> {
    return gateway("media", { phoneNumberId: account.phoneNumberId, mediaId });
  }

  // Optional: uploadMedia?(account, input), markRead?(account, metaMessageId),
  // handleWebhook?(request) → Response, subscribe?(handler) → unsubscribe.
}

With a transport that isn't metaDirect, sendo.handleWebhook delegates to transport.handleWebhook (and throws ConfigError when it has none), and sendo.templates.sync() throws NotImplementedError. Media uploads and markRead throw NotImplementedError when the transport doesn't implement them.

Typed errors#

Every error extends SendoError and carries a stable string code. Branch on instanceof or on code; toJSON() never includes cause or stack, and Meta error strings are redacted.

ClasscodeFields / thrown when
WindowClosedErrorWINDOW_CLOSEDconversationId, expiresAt. Free-form send outside the 24-hour window.
TemplateNotFoundErrorTEMPLATE_NOT_FOUNDtemplateName, language, accountId, reason (missing | not_approved | inactive).
TemplateVariablesErrorTEMPLATE_VARIABLEStemplateName, expected, received.
MetaApiErrorMETA_API_ERRORstatus, metaCode (Meta's numeric code), subcode, fbtraceId, type, details, hint (billing_not_ready | token_invalid | window_closed | rate_limited | null); isInvalidToken for 190.
AccountNotFoundErrorACCOUNT_NOT_FOUNDaccount, tenantId. Unknown or inactive account, another tenant's account, or none in the tenant.
AmbiguousAccountErrorAMBIGUOUS_ACCOUNTcandidates (account ids; only the tenant's when scoped), tenantId. Several numbers, no default.
DuplicateAccountErrorACCOUNT_EXISTSphoneNumberId. accounts add / insert of a number connected (or disconnected in another tenant).
AccountStateErrorACCOUNT_STATEaccountId, state. purge of an active number, setDefault of a disconnected one.
MediaTooLargeErrorMEDIA_TOO_LARGEmediaType, sizeBytes, limitBytes.
UnsupportedMediaTypeErrorUNSUPPORTED_MEDIA_TYPEmediaType, mimeType.
SignatureErrorINVALID_SIGNATUREInvalid webhook signature (handleWebhook answers 401 instead of throwing).
InvalidPhoneErrorINVALID_PHONEinput. Number can't be normalized to E.164.
PayloadValidationErrorINVALID_PAYLOADfield, limit. A Meta message limit was exceeded (e.g. > 3 buttons).
RecordNotFoundErrorRECORD_NOT_FOUNDentity, id. Unknown conversation / message / event, or another tenant's.
ConfigErrorCONFIG_ERRORInvalid config, missing token, agent conflicts.
NotImplementedErrorNOT_IMPLEMENTEDfeature. The configured transport lacks the method: media uploads, markRead, or templates.sync on a transport that isn't metaDirect.
EmbeddedSignupErrorSIGNUP_CODE_EXPIRED, META_PERMISSION_MISSING, PIN_CONFLICT, SIGNUP_FAILEDstep, metaCode, subcode, fbtraceId, details. accounts.completeEmbeddedSignup (Embedded Signup).
ts
try {
  return await sendo.send.text(phone, "¿Seguimos con tu cita?");
} catch (error) {
  if (error instanceof WindowClosedError) {
    // Free-form text is blocked 24 h after the contact's last message: use a template.
    return sendo.send.template(phone, "recordatorio_cita", {
      language: "es",
      variables: ["Ana"],
    });
  }
  if (error instanceof TemplateNotFoundError) console.error(error.templateName, error.language);
  if (error instanceof AmbiguousAccountError)
    console.error("Pass { account }:", error.candidates);
  if (error instanceof MetaApiError) {
    console.error(error.status, error.metaCode, error.subcode, error.fbtraceId);
  }
  if (isSendoError(error)) {
    // Stable string codes, safe to branch on and to log: error.toJSON() has no cause/stack.
    console.error(error.code, error.toJSON());
  }
  throw error;
}

Utilities#

ts
normalizePhone("+502 5555-1234"); // "+50255551234"
normalizePhone("5555 1234", { defaultCountry: "GT" }); // "+50255551234"
isValidPhone("123"); // false (normalizePhone throws InvalidPhoneError)

windowStatus(lastInboundAt); // { open, expiresAt } — 24 h after the last inbound message

// Lower-level webhook helpers (sendo.handleWebhook already does this for you).
if (verifySignature(rawBody, signature, process.env.SENDO_META_APP_SECRET ?? "")) {
  for (const change of parseWebhook(JSON.parse(rawBody))) {
    console.log(change.phoneNumberId, change.messages.length, change.statuses.length);
  }
}

console.log(GRAPH_API_VERSION, SENDO_SCHEMA_VERSION, META_MEDIA_LIMITS.image); // "v26.0", 3 (schema version), bytes

Sendo Connect contract (shared with the Connect service; see Sendo Connect): signConnectBody (one or more secrets), verifyConnectSignature, createConnectClient, ConnectClient, ConnectRequestError, ConnectSignupRequest, ConnectSignupResult, ConnectWebhookStatus, CONNECT_SIGNUP_STEPS / ConnectSignupStep, CONNECT_ERROR_CODES / ConnectErrorCode and CONNECT_TEST_EVENT_OBJECT.

Also exported: signBody, verifyChallenge, parseInboundMessage, parseStatusUpdate, windowExpiresAt, canTransition / nextStatus, createGraphClient, the payload builders (buildTextPayload…) and createRedactingLogger.