API reference
Config, events, sending, templates, conversations, agents and adapters.
Entry points#
| Import | Exports | Needs |
|---|---|---|
@quami/sendo | createSendo, types, errors, metaDirect, agentTool, utilities, runDoctor / addAccount | zod 4, ai 6 (peers) |
@quami/sendo/adapters/supabase | supabaseAdapter | @supabase/supabase-js |
@quami/sendo/adapters/memory | memoryAdapter | — |
@quami/sendo/adapters/drizzle | drizzleAdapter, DrizzleAdapterError | drizzle-orm |
@quami/sendo/adapters/prisma | prismaAdapter, PrismaAdapterError | @prisma/client |
@quami/sendo/adapters/contract | runAdapterContract, runMediaStoreContract | vitest |
@quami/sendo/storage/supabase | supabaseStorage | @supabase/supabase-js |
@quami/sendo/storage/s3 | s3Storage, S3StorageError | @aws-sdk/client-s3, @aws-sdk/s3-request-presigner |
@quami/sendo/snippets | Client-safe snippet helpers: searchSnippets, findSnippetVariables, fillSnippetVariables, limits | — |
@quami/sendo/rules | Client-safe rule schemas: ruleParamsSchemas, ruleKeySchema, WEEKDAYS, parseRuleParams, rule types | zod 4 |
@quami/sendo/next | toNextHandlers | next |
npx @quami/sendo | doctor, 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#
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,
});| Option | Type | Default / notes |
|---|---|---|
adapter | SendoAdapter | Required. supabaseAdapter or memoryAdapter. |
media | MediaStore | Where media bytes live (MediaStore). Default: the adapter's storage. |
appSecret | string | Required unless transport or connect is set. Verifies X-Hub-Signature-256. |
verifyToken | string | Required unless transport is set. Webhook GET challenge. |
resolveToken | (account) => string | null | undefined | Promise<…> | Required unless transport is set. Called per Meta call; empty → ConfigError. |
transport | Transport | Replaces the three options above (metaDirect(...) or your own Transport). |
graphVersion | string | GRAPH_API_VERSION ("v26.0"). Shorthand config only. |
fetch | typeof fetch | globalThis.fetch. Shorthand config only. |
linkContact | ({ phone, account, contact }) => string | null | Called 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 |
eventRetentionDays | number | 30 (sendo.maintenance.prune) |
locale | "en" | "es" | "en". Language of the built-in handoff and opt-out messages (BUILT_IN_MESSAGES[locale]). |
handoffMessage | string | null | BUILT_IN_MESSAGES[locale].handoff ("Connecting you with someone from the team."); null: none. |
defaultCountry | string | ISO-2 or calling code for national numbers, e.g. "GT". |
logger | Logger | Console-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? } | null | Required 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.
| Event | Payload | When |
|---|---|---|
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. |
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 listeningSending (sendo.send)#
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:
- normalizes the number to E.164 (
InvalidPhoneError); - resolves the account: the
accountoption, else thetenant's default (or only) number, else the only active account;AmbiguousAccountErrorwhen that is not one number (resolution order); - gets or creates the contact and the conversation;
- checks the 24-hour window, except for templates (
WindowClosedError, before any write or Meta call); - inserts a
sendo_messagesrow with statuspending(sendersystem); - calls Meta (file media is uploaded first);
- stores the Meta id and status
sent, orfailedwith the error and rethrows.
// 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 messagereply#
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#
// 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. ReturnsTemplate[].list({ account?, tenant?, status?, includeInactive? })readssendo_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'sPOST /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 oneSendoExportDocumentV1JSON document (the number's contacts, tags, conversations, messages, notes, system events and media references), streamed and a snapshot as ofexportedAt. Admins only: any other actor getsForbiddenErroron the call, before any chunk.exportFileName(displayPhone, exportedAt)names the download. See Exporting a number's history.
sendo.conversations#
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? }): statushuman, reason stored,resolveHandoffTargetscalled (one assignee →assignedTo), the handoff message sent,handoffevent emitted.returnToAgent(id)clearsassignedToandhandoffReason;close(id)closes it. A new inbound message reopens a closed conversation with statusagent.assign(id, userId)also moves the conversation tohuman;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. Emitsconversation.agent_paused.by(default"system") shows up in theconversation.status,conversation.assignedandconversation.agent_pausedevents 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.
| Method | Returns | Notes |
|---|---|---|
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 }) | InboxConversation | One 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 }) | InboxMessage | Step-in: sender human, status unchanged, pending draft discarded. WindowClosedError outside the window. |
note(id, text, { actor }) | InboxMessage | Internal note, never sent. |
take / returnToAgent / close(id, { actor }) | InboxConversation | Take assigns the actor and moves to human. |
assign(id, userId | null, { actor }) | InboxConversation | Anyone but the actor needs conversations.reassign. |
pauseAgent(id, paused, { actor }) | InboxConversation | Needs conversations.pause_agent. |
approveDraft(messageId, { actor, text? }) / discardDraft(messageId, { actor }) | InboxMessage / void | WindowClosedError when the window closed. |
markSeen(id, { actor }) | void | Resets 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).
sendo.snippets, sendo.tags and sendo.links#
Every method takes { actor } and is scoped to the actor's tenant.
| Method | Returns | Notes |
|---|---|---|
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 }) | Snippet | Someone else's personal snippet is RecordNotFoundError. |
snippets.upsert({ id?, shortcut, body, visibility?, accountId? }, { actor }) | Snippet | Team 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 / number | Need 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 aqueuedbroadcast. Recipients are contact ids,{ phone }(upserted as a contact) or{ contactId | phone, variables }. MARKETING templates go only to contacts withmarketingOptIn = true; UTILITY and AUTHENTICATION go to everyone who hasn't explicitly opted out. Ineligible recipients are storedskippedwith the reason.ratePerMinutedefaults to 60.get(id)addscountsper recipient status,recipients(id, { status?, cursor?, limit? })pages the per-recipient results,list({ cursor?, limit? })is newest first, andcancel(id)turns pending recipients intocancelled.sendo.maintenance.tick()sends up toratePerMinuterecipients per broadcast and minute. Status webhooks update each recipient. Meta 131042 pauses the broadcast (pauseReason: "billing", numberbillingReady = false) until billing is ready again; a rate limit (130429) leaves recipientspendingfor the next tick. Broadcast messages never move a conversation tohumanor run the agent; replies land in the normal conversation.sendo.contacts.setOptIn(contactId, optIn, { source? })records an explicit decision (marketingOptIn,optInAt = now) and emitscontact.opt_in. An inbound message that is exactly anoptOutkeyword (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.
| Method | Returns | Notes |
|---|---|---|
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.
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);
}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):
| Request | Response |
|---|---|
| GET with the matching hub.verify_token | 200 with hub.challenge (text/plain) |
| GET with a wrong or missing token | 403 |
| POST with a missing or invalid X-Hub-Signature-256 | 401, nothing stored |
With connect: POST with a missing or invalid X-Sendo-Signature | 401, 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 JSON | 400 |
| POST, event could not be stored | 500 (Meta retries) |
| POST, event stored | 200 immediately; processing continues via waitUntil |
| Any other method | 405 |
// 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 (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.
| Field | Notes |
|---|---|
name | Unique; 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". |
model | Gateway id string ("openai/gpt-5-mini") or an AI SDK LanguageModel. |
instructions | string 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. |
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,
});
},
});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(); // unregisterOne turn, per conversation:
- 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. - Skips the turn when the contact sent more than
maxMessagesPerMinutePerContactmessages in the last minute (messages are still stored). - 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 lasthistoryLimitmessages of this conversation only. - Runs
generateTextwithstopWhen: stepCountIs(maxStepsPerTurn). Tool input is validated with zod beforerun; each tool receives{ input, contact, conversation, account, sendo }. - 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 tosendo_messages.metadata, never to the user. Notes, system lines and drafts are never part of the model's history. - Loops while new inbound messages arrived (at most 3 turns), then releases the lock. On errors: logs, sends
fallbackMessageif 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#
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#
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 testsOptions: 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 (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.
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 whatsupabaseAdapteruses, 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-s3and@aws-sdk/s3-request-presigner. Signed URLs are SigV4 presignedGetObjectURLs (TTL capped at 7 days); deletes are batched 1000 keys perDeleteObjects. Failures areS3StorageError(operation,code,status). The credentials needs3:PutObject,s3:GetObject,s3:DeleteObjectand, for doctor,s3:ListBucket(HeadBucket).
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:
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:
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.
| Class | code | Fields / thrown when |
|---|---|---|
WindowClosedError | WINDOW_CLOSED | conversationId, expiresAt. Free-form send outside the 24-hour window. |
TemplateNotFoundError | TEMPLATE_NOT_FOUND | templateName, language, accountId, reason (missing | not_approved | inactive). |
TemplateVariablesError | TEMPLATE_VARIABLES | templateName, expected, received. |
MetaApiError | META_API_ERROR | status, metaCode (Meta's numeric code), subcode, fbtraceId, type, details, hint (billing_not_ready | token_invalid | window_closed | rate_limited | null); isInvalidToken for 190. |
AccountNotFoundError | ACCOUNT_NOT_FOUND | account, tenantId. Unknown or inactive account, another tenant's account, or none in the tenant. |
AmbiguousAccountError | AMBIGUOUS_ACCOUNT | candidates (account ids; only the tenant's when scoped), tenantId. Several numbers, no default. |
DuplicateAccountError | ACCOUNT_EXISTS | phoneNumberId. accounts add / insert of a number connected (or disconnected in another tenant). |
AccountStateError | ACCOUNT_STATE | accountId, state. purge of an active number, setDefault of a disconnected one. |
MediaTooLargeError | MEDIA_TOO_LARGE | mediaType, sizeBytes, limitBytes. |
UnsupportedMediaTypeError | UNSUPPORTED_MEDIA_TYPE | mediaType, mimeType. |
SignatureError | INVALID_SIGNATURE | Invalid webhook signature (handleWebhook answers 401 instead of throwing). |
InvalidPhoneError | INVALID_PHONE | input. Number can't be normalized to E.164. |
PayloadValidationError | INVALID_PAYLOAD | field, limit. A Meta message limit was exceeded (e.g. > 3 buttons). |
RecordNotFoundError | RECORD_NOT_FOUND | entity, id. Unknown conversation / message / event, or another tenant's. |
ConfigError | CONFIG_ERROR | Invalid config, missing token, agent conflicts. |
NotImplementedError | NOT_IMPLEMENTED | feature. The configured transport lacks the method: media uploads, markRead, or templates.sync on a transport that isn't metaDirect. |
EmbeddedSignupError | SIGNUP_CODE_EXPIRED, META_PERMISSION_MISSING, PIN_CONFLICT, SIGNUP_FAILED | step, metaCode, subcode, fbtraceId, details. accounts.completeEmbeddedSignup (Embedded Signup). |
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#
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), bytesSendo 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.