# Sendo > Sendo (`@quami/sendo`) connects a Next.js + Supabase app to the official WhatsApp Cloud API: a typed client with AI agents, plus shadcn registry items for the webhook, the schema and a staff inbox. Your data stays in your database. --- # Quickstart URL: https://sendo.quami.dev/docs/quickstart > From an empty Next.js + Supabase app to the first agent reply. Nine steps from an existing Next.js App Router + Supabase app to an AI agent answering “hola” on WhatsApp. Budget under an hour; most of it is the [Meta setup](https://sendo.quami.dev/docs/meta-setup), so open that page in another tab. > **Note: Before you start** > > - Next.js 15+ (App Router), Node 20+, a Supabase project (or `supabase start` locally) and the Supabase CLI. > - A Meta developer app with the WhatsApp product, a test number and a system user token (see [Meta setup](https://sendo.quami.dev/docs/meta-setup)). If you are starting from zero, Meta’s business verification and app review can take days or weeks; the hour budget starts once you have the app. > - Everything is public and MIT licensed: no npm or registry token is needed. ## 1. Install the package `zod` (v4) and `ai` (v6) are peer dependencies, so install them together: ```shell pnpm add @quami/sendo ai zod ``` ## 2. Add the webhook and schema items Registry items are installed with the standard shadcn CLI from the `@sendo` namespace: ```shell npx shadcn add @sendo/webhook @sendo/schema ``` - `webhook`: `app/api/sendo/webhook/route.ts` and `lib/sendo.ts` (the client config with TODO hooks). Adds `@supabase/supabase-js`. - `schema`: the migrations, in order: `supabase/migrations/20261010000000_sendo_v1.sql`, `20261011000000_sendo_v2.sql` (team inbox: snippets, tags, rules) and `20261013000000_sendo_v3.sql` (broadcasts and metrics). Later versions add files; the [changelog](https://sendo.quami.dev/changelog#schema-versions) lists them. The installed route is three lines: ```ts title="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; ``` If shadcn answers that it doesn't know `@sendo`, add the registry to your `components.json` and run the command again. This is needed only until shadcn's registry index lists `@sendo`; after that the entry is optional. ```json title="components.json" { "registries": { "@sendo": "https://registry.sendo.quami.dev/r/{name}.json" } } ``` ## 3. Add the inbox ```shell npx shadcn add @sendo/inbox ``` The staff inbox at `/inbox` (see [Inbox](https://sendo.quami.dev/docs/inbox)). The other screens (numbers, rules, broadcasts, metrics, links) are separate items you add the same way. ## 4. Run the migrations and implement sendo_can_access_tenant ```shell supabase migration up # local supabase db push # linked project ``` The migrations create the `sendo_*` tables with RLS on every table. The package uses the service role and bypasses RLS; browser clients (the inbox’s realtime subscriptions) only see rows for which `sendo_can_access_tenant(tenant_id)` returns true. It returns `false` by default, so replace it in a migration of your own (keep the signature and the parameter name): ```sql title="supabase/migrations/_sendo_can_access_tenant.sql" -- Multi-tenant: a user sees the sendo_* rows of the tenants they belong to. create or replace function public.sendo_can_access_tenant(tenant_id uuid) returns boolean language sql stable security definer set search_path = '' as $fn$ select exists ( select 1 from public.memberships m where m.user_id = auth.uid() and m.tenant_id = sendo_can_access_tenant.tenant_id ); $fn$; ``` For a single-tenant app, where every `tenant_id` is null: ```sql title="sql" -- Single tenant (tenant_id is null): only signed-in staff. create or replace function public.sendo_can_access_tenant(tenant_id uuid) returns boolean language sql stable security definer set search_path = '' as $fn$ select exists ( select 1 from public.staff s where s.user_id = auth.uid() ); $fn$; ``` Inbound media goes to the **private** `sendo-media` Storage bucket. The migration creates it, so there is nothing to run here. ## 5. Set the environment variables ```dotenv title=".env.local" # Meta app: App settings > Basic > App secret SENDO_META_APP_SECRET= # Any random string; paste the same value in the Meta webhook config SENDO_VERIFY_TOKEN= # One env var per number; sendo_accounts.token_ref stores this NAME, never the token SENDO_TOKEN_MAIN= # Gateway model id (needs AI_GATEWAY_API_KEY) or wire a provider model in lib/sendo.ts SENDO_AGENT_MODEL=openai/gpt-5-mini AI_GATEWAY_API_KEY= # Supabase (server only for the service role key) NEXT_PUBLIC_SUPABASE_URL= SUPABASE_SERVICE_ROLE_KEY= SENDO_STORAGE_BUCKET=sendo-media # Optional: doctor checks this app is subscribed to your WABA SENDO_META_APP_ID= ``` `SENDO_TOKEN_MAIN` is only a name: each `sendo_accounts` row stores the name of the env var holding its token in `token_ref`, and `resolveToken` reads it at call time. The token is never written to the database or logs. A gateway model id such as `openai/gpt-5-mini` needs AI Gateway credentials; any AI SDK v6 `LanguageModel` works too. Then fill in the hooks in `lib/sendo.ts` and register an agent. The complete file: ```ts title="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, }); ``` Append the agent to the same file: ```ts title="lib/sendo.ts (continued)" sendo.agent({ name: "recepcion", model: requireEnv("SENDO_AGENT_MODEL"), // e.g. "openai/gpt-5-mini" via the AI Gateway instructions: "Eres la recepcionista de la clínica. Responde breve y en español.", tools: { horarios: { description: "Horarios de atención de la clínica", input: z.object({}), run: async () => ({ lunesAViernes: "08:00-17:00", sabado: "08:00-12:00" }), }, }, handoff: { when: "El paciente pide hablar con una persona", to: "recepcion" }, fallbackMessage: "Tuvimos un problema. Una persona del equipo te responderá pronto.", }); ``` ## 6. Register the number in sendo_accounts ```shell npx @quami/sendo accounts add --phone-number-id 123456789012345 --token-env SENDO_TOKEN_MAIN ``` It reads `.env.local`, refuses to run when the token variable is empty, fetches the display phone and (when possible) the WABA id from Meta, and stores only the variable name. Flags and prompts: [CLI](https://sendo.quami.dev/docs/cli#accounts-add). Or insert the row yourself: ```sql title="sql" -- Alternative to `npx @quami/sendo accounts add`. token_ref is the env var NAME. insert into public.sendo_accounts (tenant_id, phone_number_id, waba_id, display_phone, token_ref) values (null, '123456789012345', '987654321098765', '+1 555 000 1111', 'SENDO_TOKEN_MAIN'); ``` ## 7. Point the Meta webhook at your app In the Meta App Dashboard, **WhatsApp › Configuration**: set the Callback URL to `https:///api/sendo/webhook` and the Verify token to the value of `SENDO_VERIFY_TOKEN`, click **Verify and save**, then subscribe the `messages` webhook field. In development, expose your dev server with a tunnel ([ngrok or cloudflared](https://sendo.quami.dev/docs/meta-setup#tunnel)). ## 8. Run doctor ```shell npx @quami/sendo doctor --url https:///api/sendo/webhook ``` Every check should pass: env vars, tables and schema version, the storage bucket, each account’s token and phone status, the WABA subscription, template listing and the webhook verify round-trip. Each failure prints a fix hint and the command exits with code 1. See [all checks](https://sendo.quami.dev/docs/cli#doctor). ## 9. Send “hola” From a phone added as a test recipient, send `hola` to the test number. The webhook returns 200 immediately, stores the message, and the `recepcion` agent answers within seconds. Ask for a person to see the handoff: the conversation moves to `human` and shows up in the inbox. > **Warning: No reply?** > > - Run `doctor` again and check your server logs for `[sendo]` lines. > - Look at `sendo_events`: `unrouted` means the `phone_number_id` isn’t in `sendo_accounts`; `failed` carries the error. Fix it and call `sendo.events.reprocess(id)`. > - Nothing in `sendo_events`: the webhook isn’t reaching you (URL, tunnel, `messages` subscription) or the signature fails (wrong `SENDO_META_APP_SECRET`, 401). Next: [API reference](https://sendo.quami.dev/docs/api-reference), [Security](https://sendo.quami.dev/docs/security) and [Retention](https://sendo.quami.dev/docs/retention). --- # Meta setup URL: https://sendo.quami.dev/docs/meta-setup > App, WhatsApp product, test number, system user token and webhook. Sendo only talks to the official WhatsApp Cloud API. You need a Meta Business account (business.facebook.com) and a developer account (developers.facebook.com). Meta renames menu items from time to time; the names below match the dashboard at the time of writing. ## 1. Create the Meta app 1. developers.facebook.com › **My Apps** › **Create app**. 2. Choose the **Business** type (or the “Connect with customers through WhatsApp” use case) and link it to your Meta Business account. ## 2. Add the WhatsApp product In the app dashboard, **Add product** › **WhatsApp** › **Set up**. Meta creates a WhatsApp Business Account (WABA) for the business and opens **WhatsApp › API Setup**. ## 3. Test number and recipients - API Setup shows a free **test number** with its **Phone number ID** and the **WhatsApp Business Account ID**. Copy both. - Under **To**, add up to five recipient numbers and verify them with the code Meta sends. The test number can only message verified recipients. - For production, add your own number under **WhatsApp › API Setup › Add phone number** (it must not be registered in the WhatsApp app) and complete the display name review. ## 4. System user token The temporary token on API Setup expires after 24 hours. Use a system user token, which doesn’t expire: 1. business.facebook.com › **Settings** › **Users › System users** › **Add** (role Admin). 2. **Assign assets**: your app, and the WhatsApp account with full control. 3. **Generate token**: pick the app, expiration **Never**, and the permissions `whatsapp_business_messaging` and `whatsapp_business_management`. 4. Put it in an env var such as `SENDO_TOKEN_MAIN` (locally and in your deployment). Never commit it and never store it in the database. > **Warning:** > > `whatsapp_business_management` is required for template sync and for `doctor`’s subscription and template checks. A revoked or expired token surfaces as `MetaApiError` with `metaCode` 190, fires the `account.error` event, and fails `doctor`. ## 5. App secret App dashboard › **App settings › Basic** › **App secret** › Show. This is `SENDO_META_APP_SECRET`: Sendo verifies the `X-Hub-Signature-256` HMAC of every webhook POST with it and answers 401 on a mismatch. The **App ID** on the same page is the optional `SENDO_META_APP_ID` that `doctor` uses to confirm the app is subscribed to your WABA. ## 6. Tunnel for local development Meta must reach your webhook over public HTTPS. Start the dev server, then one of: ```shell ngrok http 3000 # or cloudflared tunnel --url http://localhost:3000 ``` Use the printed `https://…` host as the callback URL. Free ngrok and quick cloudflared tunnels change host on every start, so update the callback URL (and rerun `doctor --url`) when they do. ## 7. Webhook and the messages field 1. Pick a random verify token and set it as `SENDO_VERIFY_TOKEN`, e.g. `openssl rand -hex 24`. Restart the dev server. 2. App dashboard › **WhatsApp › Configuration** › Webhook › **Edit**: Callback URL `https:///api/sendo/webhook`, Verify token = `SENDO_VERIFY_TOKEN`, **Verify and save**. Meta sends a GET challenge; Sendo answers it only when the token matches (403 otherwise). 3. Under **Webhook fields**, **Subscribe** to `messages`. It carries inbound messages and the sent / delivered / read / failed statuses. For numbers shared with the WhatsApp Business app, also subscribe to `smb_message_echoes`, `history` and `smb_app_state_sync` ([Coexistence](https://sendo.quami.dev/docs/coexistence)). 4. If `doctor` reports the app isn’t subscribed to the WABA, subscribe it with the token: ```shell curl -X POST "https://graph.facebook.com/v26.0//subscribed_apps" \ -H "Authorization: Bearer $SENDO_TOKEN_MAIN" ``` ## Where each value goes | Meta value | sendo | | ---------------------------- | --------------------------------------------------------------------------------- | | Phone number ID | `sendo_accounts.phone_number_id` (`--phone-number-id`); webhooks are routed by it | | WhatsApp Business Account ID | `sendo_accounts.waba_id` (`--waba-id`, auto-detected when possible) | | System user token | env var named by `sendo_accounts.token_ref` (`--token-env`) | | App secret | `SENDO_META_APP_SECRET` | | Verify token (yours) | `SENDO_VERIFY_TOKEN` | | App ID (optional) | `SENDO_META_APP_ID` | Back to [Quickstart step 6](https://sendo.quami.dev/docs/quickstart#account). --- # Meta pricing URL: https://sendo.quami.dev/docs/meta-pricing > Who pays Meta for WhatsApp messages, the dimensions a price depends on, where to find Meta's official rate card, and why a payment method matters. Sendo moves no money. **Meta bills the owner of the WhatsApp Business account** (the building, the clinic, your customer) directly, per delivered message. Sendo and Quami never re-bill messages. This page explains what a price depends on and links to Meta’s official rate card; it never copies Meta’s rates, which changed twice in fifteen months and change again by country, category and date. ## Official sources - **[Meta’s official rate card and pricing rules](https://developers.facebook.com/docs/whatsapp/pricing)**: how messages are billed, the categories, free entry points, announced changes and the rate cards per market. This is the only source for numbers. - [Meta Business Suite billing](https://business.facebook.com/billing_hub): where the account owner adds a payment method and sees invoices. ## What a price depends on Meta bills **per delivered message**. The price of one message depends on: - **Category.** Every template has one: **marketing**, **utility** or **authentication**. Replies inside the 24-hour customer service window are **service** messages. Pick the category honestly when you create a template; Meta re-categorizes templates whose content doesn’t match ([broadcasts](https://sendo.quami.dev/docs/broadcasts#eligibility)). - **The recipient’s country code.** The rate depends on where the number you send to is registered, not where your business is. Meta groups many countries into regional markets: **Guatemala falls under “Rest of Latin America”**, so look up that row on the rate card, not a Guatemala row. - **The date.** Meta publishes changes ahead of time on the page above; a rate card is valid for a period, not forever. Instagram and Messenger messages are not billed per message. ## Service messages are changing Meta has announced changes to how **service messages** (replies inside the 24-hour window) are billed, and providers describe the new rule differently. Sendo doesn’t claim either version: read the current rule on [Meta’s pricing page](https://developers.facebook.com/docs/whatsapp/pricing) and check your own invoices in [Meta Business Suite billing](https://business.facebook.com/billing_hub). ## A payment method on every account The account owner must add a payment method in Meta. Until they do, sends fail with Meta error 131042. Sendo tracks this as `billingReady` on each number: the numbers screen shows “falta método de pago”, sends throw with the `billing_not_ready` hint, `sendo doctor` warns, and broadcasts pause until it is fixed. See [Embedded Signup](https://sendo.quami.dev/docs/embedded-signup#billing). ## Estimating your costs Count the messages you send per month by category and by the recipient’s market, multiply by the rates on [Meta’s current rate card](https://developers.facebook.com/docs/whatsapp/pricing), and add service messages under the rule Meta applies at the time. [Metrics](https://sendo.quami.dev/docs/metrics) gives you message counts per day to check the estimate against Meta’s invoices. --- # Sendo Connect URL: https://sendo.quami.dev/docs/sendo-connect > The optional paid service: Quami as your Meta Tech Provider. Configuration, the signed webhook relay, secret rotation, the test event, signup through Connect, disconnecting numbers, suspension, doctor checks and what passes through Quami. Sendo is free and MIT licensed. The only paid piece is **Sendo Connect**: Quami acts as the Meta **Tech Provider** for your app, so you can offer “Connect your WhatsApp” without going through Meta’s business verification and app review yourself. Price: **coming soon** (per connected number, per month; see [Pricing](https://sendo.quami.dev/pricing)). > **Warning: The service is opening** > > The package side below ships in `@quami/sendo`. Connect accounts (API key, signing secret, panel) open when Quami’s Tech Provider verification is approved; until then, use [your own Meta app](https://sendo.quami.dev/docs/meta-setup). ## What changes with Connect - **Configuration only.** The package, the migrations and the registry components are identical; you add `connect: { apiKey, signingSecret }` to `createSendo`. - **Signup** goes through Quami’s Meta app: the number’s token is returned to your server once and never stored by Connect. - **Webhooks** arrive through Quami’s relay, signed with your own secret (`X-Sendo-Signature`), and are verified fail-closed by your app. - **Sending** still goes directly from your server to Meta with the customer’s token. ## Setup 1. In the Connect panel, create a project and copy the **API key** and the **signing secret** into `SENDO_CONNECT_API_KEY` and `SENDO_CONNECT_SIGNING_SECRET` (`.env.local` and your deployment). 2. Register your webhook URL in the panel: `https:///api/sendo/webhook` (the `webhook` route). You don’t configure a webhook in Meta. 3. In `lib/sendo.ts`, remove `appSecret` and add `connect`. Set `meta.appId` / `configId` (and `NEXT_PUBLIC_SENDO_META_APP_ID` / `NEXT_PUBLIC_SENDO_META_CONFIG_ID` for the connect button) to Quami’s values from the panel. 4. Run `npx @quami/sendo doctor --url https:///api/sendo/webhook` ([checks below](#doctor)). ```ts title="lib/sendo.ts" export const sendo = createSendo({ adapter: memoryAdapter(), // your adapter // No appSecret: with `connect`, webhooks are verified with X-Sendo-Signature only. verifyToken: process.env.SENDO_VERIFY_TOKEN ?? "", resolveToken: (account) => readSecret(account.tokenRef), storeToken, // Embedded Signup tokens still go to your secret store connect: { apiKey: process.env.SENDO_CONNECT_API_KEY ?? "", signingSecret: process.env.SENDO_CONNECT_SIGNING_SECRET ?? "", }, }); ``` `baseUrl` defaults to `https://connect.sendo.quami.dev`. `apiKey` and `signingSecret` are registered as secrets: the logger and errors redact them like tokens. ## Webhook signature With `connect` set, `sendo.handleWebhook` (and `toNextHandlers`) accepts a `POST` only when `X-Sendo-Signature` is valid: ``` X-Sendo-Signature: t=,v1=[,v1=] ``` - The timestamp must be within **5 minutes** of your server’s clock (either direction), so a captured request can’t be replayed later. Keep the server clock synced. - The comparison is constant-time. Several `v1=` values are allowed (any may match) so the secret can be rotated ([rotation](#rotation)). - Anything else (missing, malformed, stale, wrong secret, body changed) is `401` and nothing is stored. - **Meta’s `X-Hub-Signature-256` is not accepted** while `connect` is set, even if `appSecret` is still configured. Without `connect`, nothing changes: Meta’s signature is the only one checked. To try your route locally, sign a fixture the way the relay does with `signConnectBody` (and `verifyConnectSignature` checks one): ```ts title="ts" // Sign a body the way the relay does, to try your webhook route locally. export function relayHeaders(rawBody: string): Record { return { "content-type": "application/json", "x-sendo-signature": signConnectBody(rawBody, process.env.SENDO_CONNECT_SIGNING_SECRET ?? ""), }; } ``` `signConnectBody(rawBody, secret, timestamp?)` takes one secret or a list (`[old, new]`) and adds one `v1=` per secret, in that order. Connect’s relay signs with the same function. ### Rotating the signing secret A project can have **two active signing secrets**. While both are active, the relay sends one `v1=` per secret, and your app accepts the request when any of them matches its `SENDO_CONNECT_SIGNING_SECRET`. To rotate without dropping a webhook: 1. In the panel, add a second signing secret. Deliveries now carry two `v1=` values; your app keeps verifying with the old secret. 2. Set `SENDO_CONNECT_SIGNING_SECRET` to the new secret in `.env.local` and your deployment, and redeploy. 3. Check that deliveries still succeed (`sendo doctor`, or [Send test event](#test-event)), then revoke the old secret in the panel. The panel refuses a third active secret. ## Test event The panel’s **Send test event** button signs a synthetic body with your project’s active secrets and sends it to your webhook URL: ``` POST https:///api/sendo/webhook X-Sendo-Signature: t=,v1=[,v1=] {"object":"sendo_test"} ``` With `connect` set, `sendo.handleWebhook` checks the signature exactly as for a real webhook. A valid test event gets `200` with `Cache-Control: no-store`, and nothing is stored, processed or emitted. A missing or wrong signature is `401`, so a failed test points at `SENDO_CONNECT_SIGNING_SECRET` or the server clock. The body’s `object` is exported as `CONNECT_TEST_EVENT_OBJECT`. Without `connect`, the body gets no special handling. ## Signup through Connect `sendo.accounts.completeEmbeddedSignup(input)` takes the same input and returns the same `Account`. With `connect`, steps 1–4 of the [direct sequence](https://sendo.quami.dev/docs/embedded-signup#sequence) are one call to Connect: ``` POST {baseUrl}/v1/signup/complete Authorization: Bearer { "code", "wabaId", "phoneNumberId", "coexistence", "pin" } → 200 { "token", "displayPhone" } ``` `pin` is the admin’s PIN or a random 6 digits (`null` for coexistence numbers, which skip `register`). Then Sendo continues exactly as in the direct path: `storeToken`, insert or reconnect the account (`onboardedVia: "connect"`), first number becomes the default, billing check. A failure after `storeToken` compensates the same way (no row left, `deleteToken`, app unsubscribed). Errors keep the same codes. Connect reports Meta failures as `{ "error": { "code", "message", "step", "metaCode", "subcode", "fbtraceId" } }`, which map to `SIGNUP_CODE_EXPIRED`, `META_PERMISSION_MISSING`, `PIN_CONFLICT` or `SIGNUP_FAILED` with that `step`. A rejected API key (401) is `CONFIG_ERROR` naming `SENDO_CONNECT_API_KEY`; Connect unreachable or an answer without a token is `SIGNUP_FAILED` with `step: "connect"`. `meta.appId` and `appSecret` are not needed on your side. ## Disconnecting a number `sendo.accounts.disconnect(id)` (and `sendo accounts disconnect `) on a number with `onboardedVia: "connect"` also tells Connect, so it stops relaying and billing that number: ``` POST {baseUrl}/v1/numbers/{phoneNumberId}/disconnect Authorization: Bearer → 200 { "ok": true } · 404 NUMBER_NOT_FOUND · 403 PROJECT_SUSPENDED ``` It’s best effort: if Connect fails or can’t be reached (30-second timeout), Sendo logs a warning without the key and the number is still disconnected in your app. Disconnect it in the panel if the warning appears. The CLI notifies Connect when `SENDO_CONNECT_API_KEY` (and `SENDO_CONNECT_SIGNING_SECRET`) are set. ## Suspension When a payment fails, Connect gives the account a **7-day grace period** (with warnings by email and in the panel). After it, the project is **suspended**: - The relay keeps queuing your webhooks for their 24-hour window, but doesn’t deliver them. - Every `/v1/*` call answers `403 { "error": { "code": "PROJECT_SUSPENDED", … } }`: signup fails and disconnect notifications are only logged. - `sendo doctor` reports `connect:key` as “Connect project suspended — update payment at app.sendo.quami.dev”, with a `billing` fix. Paying un-suspends the project immediately, and anything still inside its 24-hour window is delivered. ## Errors from Connect Every 4xx and 5xx answer from Connect has the same envelope, parsed into `ConnectRequestError` (`status`, `errorCode`, `step`, `metaCode`, `subcode`, `fbtraceId`): ``` { "error": { "code", "message", "step"?, "metaCode"?, "subcode"?, "fbtraceId"? } } ``` The codes are exported as `CONNECT_ERROR_CODES`: `UNAUTHORIZED` (401, missing or invalid key), `PROJECT_SUSPENDED` (403), `NUMBER_NOT_FOUND` (404), `NUMBER_IN_USE` (409, the number is active in another project), `EARLY_ACCESS` (signup before Meta approval), `INVALID_REQUEST` (400), `META_ERROR` (a signup step failed on Meta) and `INTERNAL_ERROR` (5xx). `createConnectClient({ config: { apiKey, baseUrl } })` returns the same client the package uses (`completeSignup`, `getWebhook`, `disconnectNumber`). ## Doctor checks When `SENDO_CONNECT_API_KEY` or `SENDO_CONNECT_SIGNING_SECRET` is set, `sendo doctor` requires both (and no longer requires `SENDO_META_APP_SECRET`) and calls `GET {baseUrl}/v1/projects/current/webhook` (`SENDO_CONNECT_BASE_URL` overrides the base URL): | Check | Fails when | Fix | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `connect:key` | Connect rejects the key, or can’t be reached; “Connect project suspended” on `403 PROJECT_SUSPENDED` | Copy the current key from the panel; check `SENDO_CONNECT_BASE_URL`; when suspended, update payment at app.sendo.quami.dev | | `connect:webhook` | No URL registered, or a different one from `--url` | Register `https:///api/sendo/webhook` in the panel | | `connect:delivery` | The relay’s last delivery got a non-2xx answer (401 = signing secret mismatch or clock skew); warns when nothing was delivered yet | Fix `SENDO_CONNECT_SIGNING_SECRET` or the route; the relay retries for 24 h | ## What stays the same - Meta bills message costs to your customer’s WhatsApp Business account, not to Quami ([Meta pricing](https://sendo.quami.dev/docs/meta-pricing)). Connect is billed per connected number. - Before charging, Connect shows the billing caveat: “Meta bills messages directly to your customer; they need a card on their account.” Until the customer adds a card, sends fail with 131042 and the number shows “payment method missing” ([billing](https://sendo.quami.dev/docs/embedded-signup#billing)). - Your data stays in your database. ## Privacy With Connect, every webhook for your customers’ numbers passes through Quami’s relay before it reaches your app. What Connect sees and keeps: - A webhook body is **held encrypted only while its delivery is pending**, and deleted on success or when the 24-hour retry window ends. - Bodies are **never logged**. - Only delivery metadata (timestamp, project, number, status code, attempt count, latency) is kept, for **7 days**. - **Quami’s Meta app** is the one connected to your customers’ WhatsApp accounts. The number’s access token is returned to your server once and never stored by Connect. The [pricing page](https://sendo.quami.dev/pricing) states exactly this. Apps with sensitive data (health, for example) should use **their own Meta app** instead, so no third party sits in the message path. ## Doing it yourself Being your own Tech Provider stays a first-class, free path: see [Meta setup](https://sendo.quami.dev/docs/meta-setup) and [Embedded Signup](https://sendo.quami.dev/docs/embedded-signup). --- # API reference URL: https://sendo.quami.dev/docs/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`](https://sendo.quami.dev/docs/adapters#drizzle) | `drizzle-orm` | | `@quami/sendo/adapters/prisma` | [`prismaAdapter`, `PrismaAdapterError`](https://sendo.quami.dev/docs/adapters#prisma) | `@prisma/client` | | `@quami/sendo/adapters/contract` | `runAdapterContract`, `runMediaStoreContract` | `vitest` | | `@quami/sendo/storage/supabase` | [`supabaseStorage`](#media-store) | `@supabase/supabase-js` | | `@quami/sendo/storage/s3` | [`s3Storage`, `S3StorageError`](#media-store) | `@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](https://sendo.quami.dev/docs/cli) | — | 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 ```ts title="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, }); ``` | Option | Type | Default / notes | | ------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `adapter` | `SendoAdapter` | Required. supabaseAdapter or memoryAdapter. | | `media` | `MediaStore` | Where media bytes live ([MediaStore](#media-store)). 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` | Embedded Signup token storage; returns the tokenRef. | | `deleteToken` | `(tokenRef) => Promise` | 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](https://sendo.quami.dev/docs/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](https://sendo.quami.dev/docs/voice-notes-and-cost)). | | `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` | 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. | ```ts title="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) ```ts title="signatures" sendo.send.text(to, body, { account?, tenant?, previewUrl?, replyTo? }): Promise 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 ``` 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](https://sendo.quami.dev/docs/numbers-and-tenants#resolution)); 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 title="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 ``` > **Note:** > > Templates are read from `sendo_templates`, not from Meta: call `sendo.templates.sync()` after creating or editing templates, or `send.template` throws `TemplateNotFoundError`. A wrong number of variables throws `TemplateVariablesError`. Media is checked against Meta’s limits (image 5 MB, audio/video 16 MB, document 100 MB, sticker 500 KB) before upload. ## 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 title="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](https://sendo.quami.dev/docs/numbers-and-tenants#accounts). - `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](https://sendo.quami.dev/docs/sendo-connect#disconnect). - `export(id, { actor })` → `AsyncIterable`: 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](https://sendo.quami.dev/docs/retention#export). ## sendo.conversations ```ts title="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](https://sendo.quami.dev/docs/team-inbox#roles)): 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` | 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` | 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](https://sendo.quami.dev/docs/team-inbox#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](https://sendo.quami.dev/docs/team-inbox#snippets-tags) and [Widgets](https://sendo.quami.dev/docs/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`. | 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:`, `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](https://sendo.quami.dev/docs/metrics#sql)). ## 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 title="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 title="ts" const deleted = await sendo.maintenance.prune(); // eventRetentionDays (default 30) await sendo.maintenance.prune({ olderThanDays: 7 }); ``` Scheduling the prune: [Retention](https://sendo.quami.dev/docs/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](https://sendo.quami.dev/docs/sendo-connect#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 | ```ts title="ts" // Any runtime with Web Request/Response (Hono, Supabase Edge Functions, Workers...). const worker = { fetch(request: Request, _env: unknown, ctx: { waitUntil(p: Promise): 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. ```ts title="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. | 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](https://sendo.quami.dev/docs/numbers-and-tenants#agents). | | `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`. | | `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](https://sendo.quami.dev/docs/team-inbox#cost). | ```ts title="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 title="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:` 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:`) 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 title="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](#media-store) (`createSendo({ media })` overrides it). Database errors surface as `SupabaseAdapterError` (`code`, `operation`, `details`, `hint`). ### memoryAdapter ```ts title="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: ```ts title="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; declare function truncateTables(): Promise; 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; get(path: string): Promise; // null when missing signedUrl(path: string, ttlSeconds: number): Promise; delete(paths: string[]): Promise; // missing paths are ignored exists?(): Promise; // 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). ```ts title="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://.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 ` 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 title="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 title="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 { 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](https://sendo.quami.dev/docs/embedded-signup#errors)). | ```ts title="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 title="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](https://sendo.quami.dev/docs/sendo-connect#errors)): `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`. --- # Agents and models URL: https://sendo.quami.dev/docs/agents-and-models > Give an agent any AI SDK model: OpenRouter or a gateway for exploring, a direct provider for production, and what each turn costs. An agent’s `model` is any AI SDK v6 `LanguageModel`, or a gateway model id string. Sendo never picks a provider for you and has no model bundled. ```ts import { openai } from "@ai-sdk/openai"; sendo.agent({ name: "recepcion", model: openai("gpt-5-mini"), // or "openai/gpt-5-mini" through the AI Gateway instructions: "Eres la recepcionista de la clínica. Responde breve y en español.", pricing: { inputPerMTok: 0.25, outputPerMTok: 2 }, // optional, USD; used for costCents }); ``` The full definition (tools, handoff, draft mode, limits) is in the [API reference](https://sendo.quami.dev/docs/api-reference#agents). ## For exploring: one key, many models **OpenRouter** (through its AI SDK provider) or the **Vercel AI Gateway** (a string id such as `"openai/gpt-5-mini"`) let you try many models with one key and switch by changing a string. That is the fastest way to find a model that is good and cheap enough for your conversations. Two costs to know about: - **A markup.** These services add a fee on top of the provider’s price. Check their current pricing pages; Sendo doesn’t copy rates. - **An extra network hop.** Every turn goes through one more service, which adds latency and one more thing that can fail. ## For production: a direct provider Once you know the model, call its provider directly (`@ai-sdk/openai`, `@ai-sdk/anthropic`, `@ai-sdk/google`, …): no markup, one hop fewer, and the provider’s own data terms. For sensitive data (health, finance), read those terms and prefer providers with zero data retention agreements. Changing the model is one line in `lib/sendo.ts`; nothing else in Sendo depends on it. ## What a turn costs Every agent turn stores `tokensIn` and `tokensOut` on the reply (or draft) message, and `costCents`: 1. the provider’s reported cost when it gives one (OpenRouter reports it per call); 2. otherwise `pricing` from the agent definition (USD per million input and output tokens); 3. otherwise `null`, shown as “—”. Admins see the cost of each turn in the inbox; [Metrics](https://sendo.quami.dev/docs/metrics) adds cost per day and per agent. See [Voice notes and cost](https://sendo.quami.dev/docs/voice-notes-and-cost). ## Keep turns bounded - `historyLimit` (default 20 messages) bounds what the model reads. - `maxStepsPerTurn` (default 5) bounds tool calls per turn. - `maxMessagesPerMinutePerContact` (default 10) skips turns for floods; messages are still stored. - To cap output tokens, wrap the model with the AI SDK’s `wrapLanguageModel` and a middleware that sets `maxOutputTokens` (the [live demo](https://sendo.quami.dev/demo) caps every call at 300 this way). ## One agent for many organizations `tenants` and `accounts` restrict where an agent answers, and `instructions` can be a function of the account’s tenant. See [Numbers and tenants](https://sendo.quami.dev/docs/numbers-and-tenants#agents). --- # Inbox URL: https://sendo.quami.dev/docs/inbox > Layout, setup, languages and strings, motion tokens and server-action auth for the team inbox. `@sendo/inbox` is the team inbox. The files are copied into your app, so you own and edit them. The operator work it supports (step in, pause, notes, drafts, assignment, tags, roles) is described in [Team inbox](https://sendo.quami.dev/docs/team-inbox); this page covers the UI item. ## Layout Four columns on wide screens: 1. **Icon rail**: Inbox, then Numbers, Rules, Metrics and Links and QR when those items are installed (`inboxNav` in `inbox-config.ts`), and the signed-in user. 2. **Conversation list**: number selector (“All active numbers” by default; “Show disconnected” or picking a disconnected number brings back its conversations, marked “· disconnected” and read-only; search always includes them), search (name, app reference or phone), status tabs with counts (Open, Assigned to me, AI agent, People, Closed) and tag filter chips (any-match). Every filter runs on the server. Each row shows the name, the app reference (or “Not linked”), the last message, “Draft” and “Paused” flags, tags, time, unread count, the assignee’s avatar, the channel badge and the 24-hour window. 3. **Thread**: status badge, the **Pause agent** switch (supervisors and admins), **Return to agent**, **Take conversation** and **Close**. Banners above the messages show why the conversation was handed to a person (only while it is with a person), that the agent is paused (with **Resume**), and that the number was disconnected. Messages are drawn by kind: the contact (with an “Origin” chip for `[#code]` links, code removed), the agent (and who approved its draft), people on the team, replies sent from the WhatsApp Business app (“From the phone”), imported history, internal notes and system lines, with day separators. A pending draft appears as a card with **Discard**, **Edit** and **Approve and send**. 4. **Context panel** (300 px): contact and app link, assignment and queue, tags, channel, number, origin, window and, for admins, the agent’s cost. Below 1180 px the context panel opens from the thread header as a side panel; on phones it is a bottom sheet and the list and the thread take turns. ## What gets installed | Path | Purpose | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `components/sendo/*.tsx` | inbox, inbox-rail, conversation-list, thread, thread-banners, message-bubble, note-bubble, system-line, draft-card, context-panel, assignee-avatar, channel-badge, tag-chip, window-indicator, composer, template-picker, status-ticks, media-preview, motion-sheet | | `components/sendo/strings.ts, types.ts, utils.ts` | English and Spanish strings, view types and the actions contract, locale-aware formatters | | `components/sendo/strings-context.tsx` | The strings context (`{ strings, locale, formatLocale }`) and its hooks | | `lib/sendo-ui.ts` | `sendoLocale`: the language of every Sendo screen ([Languages](https://sendo.quami.dev/docs/languages)); the same file ships with every UI item | | `components/sendo/motion.css` | Motion tokens (below) | | `app/(protected)/inbox/page.tsx` | The inbox page: resolves the actor, its permissions, the numbers, the tag catalog and the first page | | `app/(protected)/inbox/actions.ts` | Server actions calling `sendo.inbox.*` and `sendo.tags.list` with the actor from `requireUser()` and `sendo.actor()` | | `app/(protected)/inbox/inbox-config.ts` | Yours to edit (below) | | `hooks/use-sendo-realtime.ts` | Supabase Realtime subscription; polls instead with a null client or a failed channel | | `lib/sendo-realtime.ts` | Browser Supabase client factory for live updates | shadcn dependencies (button, input, textarea, avatar, dialog, select, switch, tooltip, sonner) and the npm packages `date-fns`, `motion` and `radix-ui` are installed with it. It needs schema v2 (`@sendo/schema`). Styling uses your theme tokens, light and dark. ## Setup `inbox-config.ts` is the only file you have to edit: | Export | What to do | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `requireUser()` | Return the signed-in user’s id and tenant from your auth, or redirect to your login. It throws until you implement it. | | `inboxRealtimeEnabled()` | Live updates through Supabase Realtime (on when the public Supabase env vars are set). | | `inboxNav` | Paths of the other Sendo screens you installed (`/inbox/numbers`, `/inbox/rules`, `/inbox/metrics`, `/inbox/links`); `null` hides the rail entry. | | `resolveUserNames(ids)` | Display names for note authors, approvers and the signed-in user. Use the same source as `resolveUsers` in `lib/sendo.ts`. | | `contactAppHref(contact)` | Link for “View … in the app” from the contact’s `appRef`; `null` shows the reference as text. | | `inboxAgentNames` | Display names for your agents, e.g. `{ recepcion: "Recepción IA" }`. | Also configure `resolveRole` (and `resolveUsers`, `resolveAssignees`) in `lib/sendo.ts`: a user whose role resolves to `null` sees “You don’t have access to the inbox”. Set the language in `lib/sendo-ui.ts` ([Languages](https://sendo.quami.dev/docs/languages)). ## Server-action auth Every server action calls your auth check first, and the template’s `requireUser()` throws until you implement it (fail closed). It then resolves the user as a Sendo actor through your `resolveRole`; every `sendo.inbox.*` call takes it and enforces the role matrix ([Team inbox](https://sendo.quami.dev/docs/team-inbox#roles)): ```ts title="app/(protected)/inbox/actions.ts" interface AppUser { id: string; tenantId: string | null; } // Fail closed: throw until you wire your app's session check. async function requireUser(): Promise { throw new Error("TODO: requireUser() — return the signed-in staff user or throw."); } // The acting user: role and queues come from `resolveRole` in lib/sendo.ts. // No `resolveRole`, or no role in the tenant, throws ForbiddenError (FORBIDDEN). async function requireActor() { const user = await requireUser(); return sendo.actor(user.id, user.tenantId); } ``` ```ts title="app/(protected)/inbox/actions.ts" export async function listConversations(cursor?: string) { const actor = await requireActor(); // Agents only get their own conversations and unassigned ones in their queues. return sendo.inbox.list({ actor, status: ["human", "agent"], cursor }); } export async function openThread(conversationId: string, cursor?: string) { const actor = await requireActor(); const page = await sendo.inbox.thread(conversationId, { actor, cursor }); await sendo.inbox.markSeen(conversationId, { actor }); return { ...page, items: page.items.toReversed() }; // newest first → chronological } export async function reply(conversationId: string, payload: InboxReplyPayload) { // Step-in: sends as the user; the status and the agent don't change. return sendo.inbox.reply(conversationId, payload, { actor: await requireActor() }); } export async function take(conversationId: string) { return sendo.inbox.take(conversationId, { actor: await requireActor() }); } ``` - `reply` stores the message as sender `human` with `senderRef` = your user id, without changing the status. Outside the window, free-form replies throw `WindowClosedError` before calling Meta, and the composer switches to the template picker. - A conversation of another tenant is `RECORD_NOT_FOUND`; one the user may not see or act on is `FORBIDDEN`. - The page also computes what the role allows (`can(actor, …)`): the pause switch, reassigning and the agent cost are hidden from roles without them. The package checks again on every call. - Media URLs in `thread` are signed for 300 seconds; request the thread again rather than storing them. ## The actions contract `` receives the server actions as one object. The page passes the ones in `actions.ts`; you can pass your own implementation (another backend, a test double). Every action resolves to a result instead of throwing, so the inbox can show the right message: ```ts title="components/sendo/types.ts" export type InboxActionResult = | { ok: true; data: T } | { ok: false; code: | "WINDOW_CLOSED" | "NOT_FOUND" | "INVALID" | "FORBIDDEN" | "ACCOUNT_INACTIVE" // the number was disconnected: "Número desconectado" | "CONFLICT" // duplicate snippet shortcut or tag name | "UNKNOWN"; message: string; }; export type InboxTab = "open" | "mine" | "agent" | "human" | "closed"; export type InboxReplyInput = | { kind: "text"; body: string } | { kind: "media"; file: File; caption?: string } | { kind: "template"; name: string; language: string; variables: string[] }; type Result = Promise>; export interface InboxActions { list(input: { tab: InboxTab; accountId?: string | null; tagIds?: string[]; // any-match search?: string; // name, app reference or phone cursor?: string | null; }): Result & { counts: Record | null }>; /** Newest first, notes, system lines and drafts included, plus display names. */ thread( conversationId: string, cursor?: string | null, ): Result & { names: Record }>; reply(conversationId: string, input: InboxReplyInput): Result; markSeen(conversationId: string): Result; take(conversationId: string): Result; assign(conversationId: string, userId: string | null): Result; returnToAgent(conversationId: string): Result; close(conversationId: string): Result; pauseAgent(conversationId: string, paused: boolean): Result; approveDraft(messageId: string, text?: string): Result; discardDraft(messageId: string): Result; addTag(conversationId: string, tagId: string): Result; removeTag(conversationId: string, tagId: string): Result; listAssignees(conversationId: string): Result; listTags(): Result; listTemplates(conversationId: string): Result; // Optional: the UI hides note mode, the snippet picker and the admin sheets without them. note?(conversationId: string, text: string): Result; listSnippets?(conversationId: string | null): Result; renderSnippet?( snippetId: string, conversationId: string, ): Result<{ text: string; unresolved: string[] }>; } ``` - `WINDOW_CLOSED` from `reply` switches the composer to the template picker and keeps what was typed; from `approveDraft` the draft card shows “The window closed; use a template”. - `ACCOUNT_INACTIVE` marks the number as disconnected: the banner appears and the composer is disabled. Reads keep working. - `FORBIDDEN` from `list` shows “You don’t have access to the inbox”. On a single conversation (for example one reassigned away from an agent) it removes the conversation from the list and closes it. - Failures show a `sonner` toast; successful actions don’t (the system line and the badge change are the feedback). ## Realtime and RLS The migration adds `sendo_conversations` and `sendo_messages` to the `supabase_realtime` publication. Browser clients use your anon key and the user’s session, so they only receive rows for which `sendo_can_access_tenant(tenant_id)` is true. With the default (`false`) the inbox gets no live updates: implement it as in [Quickstart step 4](https://sendo.quami.dev/docs/quickstart#migration). Without Supabase Realtime (no public Supabase env vars, `inboxRealtimeEnabled()` returning `false`, a Drizzle or Prisma adapter, or a channel that errors or times out) the inbox polls: the list every 10 s and the open thread every 3 s, paused while the browser tab is hidden. New inbound messages in the open thread are marked seen, as with realtime. A change patches the open list and thread right away, then the list is fetched again (previews, tags, assignee names, and visibility: a conversation reassigned away from an agent leaves their list). Tag changes made by someone else (`sendo_conversation_tags` isn’t in the publication) show up with that refetch. ## Strings The inbox renders in English or Spanish: the page passes `locale={sendoLocale}` from `lib/sendo-ui.ts` (default `"en"`), and an optional `formatLocale` (for example `"es-GT"`) for dates, times, numbers and costs. Every user-facing string comes from `inboxStrings[locale]` in `components/sendo/strings.ts` and can be overridden through the `strings` prop, on top of the chosen language. Times use the `America/Guatemala` timezone by default (`timeZone` prop). [Languages](https://sendo.quami.dev/docs/languages) covers choosing the language, regional formats and adding a third language. | Group | Keys (examples) | | ------------------------- | ----------------------------------------------------------------------------------------------------------------- | | Rail and access | `navNumbers, navRules, navMetrics, navLinks, accessDeniedTitle` | | Conversation list | `tabOpen, tabMine, tabAgent, tabHuman, tabClosed, searchPlaceholder, numberAll, notLinked, flagDraft, flagPaused` | | Thread header and banners | `pauseAgent, take, returnToAgent, close, handoffBannerTitle, pausedBannerBy(name), resume, disconnectedTitle` | | Messages | `fromPhone, importedMessage, approvedBy(name), origin(code), noteLabel(name), dayToday, actorAgent(name)` | | Draft card | `draftTitle(agent), draftAwaiting, draftBeforeHandoff, draftApprove, draftSendEdited, draftWindowClosed` | | Context panel | `sectionAssignment, queueLabel(queue), sectionTags, fieldOrigin, originDirect, sectionCost, costTurns(n)` | | Window | `windowRemaining(hours, minutes), windowClosed, windowNeverOpened` | | Composer and templates | `composerModeReply, composerModeNote, composerHintStepIn, snippetUnresolved(names), templateExplanation` | The full list is the `InboxStrings` type, with one object per language in `inboxStrings` (`components/sendo/strings.ts`); `resolveInboxStrings(locale, overrides)` merges them the way the inbox does. Some keys are functions (plurals, names); the ones that show a number or a list get `format`, which formats in `formatLocale`: ```ts title="Overriding strings" // : override any key; the rest keep that language's strings. export const inboxStringOverrides = { tabHuman: "With a person", take: "Assign to me", templateExplanation: "The 24-hour window closed. Send an approved template.", unreadCount: (count: number, format: { number: (value: number) => string }) => count === 1 ? "1 new" : `${format.number(count)} new`, }; ``` ## Motion tokens Inbox components animate only `transform` and `opacity`, with durations and curves from these custom properties: ```css title="components/sendo/motion.css" /* sendo inbox motion tokens. Override any of them in your own CSS. See MOTION.md. */ :root { --sendo-ease-out: cubic-bezier(0.16, 1, 0.3, 1); --sendo-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); --sendo-dur-press: 120ms; /* press feedback 100–160 ms */ --sendo-dur-fast: 160ms; /* tooltips, small popovers 125–200 ms */ --sendo-dur-menu: 200ms; /* dropdowns, selects 150–250 ms */ --sendo-dur-sheet: 320ms; /* dialogs, sheets 200–500 ms */ } /* * Reduced motion keeps the durations, so opacity and colour still fade (they help to follow a * state change), and removes movement: every element that moves carries `sendo-motion-transform`, * whose transform (rise on enter, press scale) is neutralised here. The mobile sheets are driven * by `motion` and switch to an opacity-only fade through `useReducedMotion()`. */ @media (prefers-reduced-motion: reduce) { .sendo-motion-transform { transform: none !important; } } ``` | Token | Used for | | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `--sendo-dur-press` | Press feedback on the rail, the thread actions, the draft buttons and send (scale 0.97 on pointer-down) | | `--sendo-dur-fast` | Status badge and status tick cross-fades, tab and tag chip colours | | `--sendo-dur-menu` | Rows, bubbles, notes and draft cards that arrive while you look; a banner that appears; the draft card leaving after approval | | `--sendo-dur-sheet` | The context side panel (768–1179 px). The phone sheets use a critically damped motion spring with drag-to-dismiss instead | | `--sendo-ease-out / --sendo-ease-in-out` | Entering elements / on-screen movement. Never ease-in. | Override them after importing `motion.css`. Under `prefers-reduced-motion: reduce` the durations stay, so opacity and colour still fade (they help to follow a state change), and movement is removed: every element that moves carries `.sendo-motion-transform`, whose transform is neutralised. The phone sheets read `useReducedMotion()` and only fade. The recipes and the reasons behind each one (and what was left out) are in the item’s `MOTION.md`. ```css title="app/globals.css" /* app/globals.css — after importing components/sendo/motion.css */ :root { --sendo-dur-menu: 160ms; /* snappier entrances */ --sendo-ease-out: cubic-bezier(0.23, 1, 0.32, 1); } /* Turn every inbox motion off (opacity and colour changes become instant too). */ .inbox-static { --sendo-dur-press: 0ms; --sendo-dur-fast: 0ms; --sendo-dur-menu: 0ms; --sendo-dur-sheet: 0ms; } ``` --- # Languages URL: https://sendo.quami.dev/docs/languages > Render the Sendo screens in English or Spanish, format dates and numbers for a region, edit any string and add a third language. The Sendo screens ship their strings in English and Spanish. Your app picks the language; the components never guess it from the browser. The registry copies the files into your app, so you can also edit any string, or add a language yourself without waiting for a release. All six UI items (inbox, numbers, broadcasts, metrics, links and QR, rules) follow the same pattern: an `Strings` object per language, a `resolveStrings(locale, overrides)` resolver and `locale` / `formatLocale` props on every root component. All of them read the same `lib/sendo-ui.ts`. Developer-facing text (CLI output, errors, doctor fixes) is English only. The messages Sendo sends to your customers (the handoff message, the opt-out confirmation) have a separate setting: `createSendo({ locale })` ([API reference](https://sendo.quami.dev/docs/api-reference)). ## Choose the language Every Sendo UI item installs the same `lib/sendo-ui.ts`: ```ts title="lib/sendo-ui.ts" export type SendoLocale = "es" | "en"; // extend to add a language export const sendoLocale: SendoLocale = "en"; // or replace with your own resolver ``` The page templates read `sendoLocale` and pass it to the root screen. The inbox page also uses it for the page title: ```tsx title="app/(protected)/inbox/page.tsx" import { inboxStrings } from "@/components/sendo/strings"; import { sendoLocale } from "@/lib/sendo-ui"; export function generateMetadata() { return { title: inboxStrings[sendoLocale].pageTitle }; } // … ; ``` Set `sendoLocale = "es"` for a Spanish app. To pick the language per user (a cookie, the user's profile), read it in the page and pass that value as `locale` instead of `sendoLocale`. `locale` is required on every root component: | Item | Roots that take `locale` | | ------------------------------------ | --------------------------------------------------------------------------------------------- | | [Inbox](https://sendo.quami.dev/docs/inbox) | `Inbox`, `SendoInbox`, `InboxAccessDenied` | | [Numbers](https://sendo.quami.dev/docs/numbers-and-tenants) | `NumbersScreen` | | [Broadcasts](https://sendo.quami.dev/docs/broadcasts) | `BroadcastsScreen`, `BroadcastsNotice` | | [Metrics](https://sendo.quami.dev/docs/metrics) | `MetricsScreen`, `MetricsNotice` | | [Links and QR](https://sendo.quami.dev/docs/widgets) | `LinksScreen`, `LinksNotice`, `LinkBuilder`, `SourcesTable`, `SendoQrCode`, `SendoChatBubble` | | [Rules](https://sendo.quami.dev/docs/workflows) | `RulesScreen`, `RulesNotice` | `SendoChatBubble` and `SendoQrCode` can end up on your public site, whose language may differ from the admin screens'. Pass them the visitor's language rather than `sendoLocale` when it does. ## Regional formats Dates, times, numbers, costs and lists go through `Intl` and date-fns in `formatLocale`, which defaults to `locale`. Pass a BCP-47 tag for regional formats: ```tsx ``` | What | `en` | `es` | `es-GT` | | ------------------ | ----------------- | ------------------- | ------------------- | | Clock time | 2:05 PM | 14:05 | 2:05 p. m. | | Last week, in list | Mon | Lun | Lun | | Day separator | Monday, October 5 | Lunes, 5 de octubre | Lunes, 5 de octubre | | Counters, tokens | 1,234 | 1234 | 1,234 | | Agent cost | $0.0024 | 0,0024 US$ | USD 0.0024 | | Lists | a, b, and c | a, b y c | a, b y c | Unicode extensions work too: `formatLocale="en-US-u-hc-h23"` keeps English formats with a 24-hour clock. The timezone is a separate prop (`timeZone`, default `America/Guatemala`). Relative times use date-fns, picked by the language part of `formatLocale` (`DATE_FNS_LOCALES` in `components/sendo/utils.ts`). ## Edit a string Override single keys with the `strings` prop. They apply on top of the chosen language, and every other key keeps its value: ```tsx (count === 1 ? "1 new" : `${format.number(count)} new`), }} /* … */ /> ``` Keys that show a number or a list receive `format` (`format.number`, `format.list`), bound to `formatLocale`. To change a string for good, edit it in `components/sendo/strings.ts`: the file is yours. ## Add a language 1. Extend the union in `lib/sendo-ui.ts`, for example `export type SendoLocale = "es" | "en" | "pt";`. If you installed several Sendo screens, they share this file. 2. Run `tsc --noEmit`. Every place that needs the new language fails to typecheck: `inboxStrings` (`satisfies Record`), the system-line sentences and `DATE_FNS_LOCALES` in `utils.ts`, plus the `Strings` object of every other screen you installed (`numbersStrings`, `broadcastsStrings`, `metricsStrings`, `widgetsStrings`, `workflowsStrings`). 3. Add the objects it asks for: `const pt: InboxStrings = { … }` (copy `en` and translate; a missing key is a type error), `SYSTEM_EVENTS_PT`, and `pt: ptBR` from `date-fns/locale`. 4. Set `sendoLocale = "pt"` or pass `locale="pt"`. With a normal component library you would file an issue and wait for a release. Here the strings are in your repo, and the type points out every gap. ## System lines Sendo stores system lines (handoff, assignment, pause, rules) as an event with params and an English `text` ([Team inbox](https://sendo.quami.dev/docs/team-inbox)). The inbox renders them in the viewer's language through `strings.systemEvent(line, names, format)`, where `names` holds the display names of who did it and of the assignee. It returns `null` for an event it doesn't know, or for an older row stored without params. The inbox then shows the stored `text`. --- # Team inbox URL: https://sendo.quami.dev/docs/team-inbox > Roles, step-in, notes, snippets and tags, agent pause, drafts and model cost in sendo.inbox. Every `sendo.inbox.*` method takes the acting user as an `actor` and checks it on the server. The actor comes from `sendo.actor(userId, tenantId)`, which calls your `resolveRole`; Sendo stores no users or roles. ## Roles `resolveRole({ userId, tenantId })` returns `{ role: "agent" | "supervisor" | "admin", queues? }` or `null` (no access). Without `resolveRole`, or with `null`, `sendo.actor` throws `ForbiddenError` (`FORBIDDEN`), so the inbox fails closed. | Can | Agent | Supervisor | Admin | | --------------------------------------------------------------------------- | ----- | ---------- | ----- | | See conversations assigned to them, plus unassigned ones in their queues | ✓ | ✓ | ✓ | | See every conversation of the tenant | | ✓ | ✓ | | Reply (step in), leave notes, tag, approve drafts on conversations they see | ✓ | ✓ | ✓ | | Take an unassigned conversation, return it to the AI, close it | ✓ | ✓ | ✓ | | Assign to someone else (or unassign), pause the AI on a conversation | | ✓ | ✓ | | See model usage and cost in the thread | | | ✓ | An unassigned conversation without a queue is in the tenant's general queue and every agent sees it. A conversation of another tenant is `RECORD_NOT_FOUND`; one of the tenant the actor may not see, or an action their role lacks, is `FORBIDDEN` (`{ capability, role }`). An agent whose conversation was reassigned to someone else gets `FORBIDDEN` on the next action. ```ts title="app/(protected)/inbox/actions.ts" interface AppUser { id: string; tenantId: string | null; } // Fail closed: throw until you wire your app's session check. async function requireUser(): Promise { throw new Error("TODO: requireUser() — return the signed-in staff user or throw."); } // The acting user: role and queues come from `resolveRole` in lib/sendo.ts. // No `resolveRole`, or no role in the tenant, throws ForbiddenError (FORBIDDEN). async function requireActor() { const user = await requireUser(); return sendo.actor(user.id, user.tenantId); } ``` ## Step in, take and assign - `reply` sends as sender `human` (`senderRef` = the actor) and changes nothing else: an `agent` conversation stays with the AI, which sees the message as `[operator] …` on its next turn. A reply also discards the agent's pending draft. - `take` assigns the actor and moves the conversation to `human`. `assign(id, userId | null)` emits `conversation.assigned { from, to, by }`. - `assignees(id)` lists who the conversation can go to: `resolveAssignees({ tenantId, queue, account })` (falling back to `resolveHandoffTargets`), with names and avatars from `resolveUsers`. - Handoffs, takes, assignments, returns to the AI, closes and pauses add a system line to the thread (`internal`, sender `system`, `metadata.kind = "system"`). Rules add theirs with `kind = "rule"`. See [System events](#system-events). ```ts title="app/(protected)/inbox/actions.ts" export async function note(conversationId: string, text: string) { return sendo.inbox.note(conversationId, text, { actor: await requireActor() }); } export async function pauseAgent(conversationId: string, paused: boolean) { // Supervisors and admins only (conversations.pause_agent). return sendo.inbox.pauseAgent(conversationId, paused, { actor: await requireActor() }); } export async function approveDraft(messageId: string, text?: string) { return sendo.inbox.approveDraft(messageId, { actor: await requireActor(), text }); } export async function assign(conversationId: string, userId: string | null) { // Anyone but yourself needs conversations.reassign (supervisors and admins). return sendo.inbox.assign(conversationId, userId, { actor: await requireActor() }); } ``` ## System events Every system line is a structured event, so the inbox can show it in the viewer's language. `thread()` returns it as `message.systemLine`: ```ts interface InboxSystemLine { kind: "system" | "rule"; event: SystemEventName | string | null; // SYSTEM_LINE_EVENTS by: string | null; // user id, "system", "business_app", "agent:" or "rule:" params: Record; } ``` The stored `text` is an English sentence, used for search and previews. To render a line, build the sentence from `event` + `params` in the viewer's locale (`by` resolves to a name the same way assignees do). If `event` is `null` or not a `SystemEventName`, show `message.text` instead. Rows written before structured events have only a Spanish `text`, and they still render that way. The `@sendo/inbox` item does this in `strings.systemEvent(line, names, format)` ([Languages](https://sendo.quami.dev/docs/languages#system-lines)). | `event` | `kind: "system"` params | `kind: "rule"` params (always `ruleKey`) | | --------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | | `handoff` | `reason`, `assignedTo` + `assignedToName` when one assignee | `queue`, `tags` (comma-separated), when the route sets them | | `assigned` | `assignedTo`, `assignedToName` (`by === assignedTo`: a take) | `assignedTo`, `assignedToName`; `minutesWaiting` = escalated | | `unassigned` | none | none | | `returned` | none | `minutesWaiting` | | `closed` | none | `afterDays` | | `agent_paused` | none | none | | `agent_resumed` | `agentResumeFrom` (newest inbound message id) | none | | `routed` | none | `queue`, `tags` | | `after_hours` | none | none | | `escalated` | none | `minutesWaiting` | When `by` is a user, `params.byName` holds their name at the time of the change. ## Notes `note(id, text, { actor })` stores an internal note: `internal = true`, sender `human`, status `sent`. It is never sent to WhatsApp, the AI never sees it, and it doesn't count as an answer to the contact. ## Snippets and tags (respuestas rápidas y etiquetas) ### Snippets A snippet has a `shortcut` (1–32 of `a-z 0-9 _ -`, typed after `/` in the composer, without the slash), a `body` of up to 4,096 characters with `{{variables}}`, a `visibility` and an optional number (`accountId`; `null` = every number). - `visibility: "tenant"` snippets are for the whole team; creating, editing or deleting them needs `snippets.manage_tenant` (admins). - `visibility: "personal"` snippets belong to their author: only they see and edit them (admins included). - Shortcuts are unique per tenant and owner; a taken one is `ConstraintViolationError` (`CONFLICT` in the inbox actions). Variables come from three built-ins and your app: | Variable | Value | | -------------- | ------------------------------------------------------------------------------ | | `{{nombre}}` | The contact's WhatsApp profile name | | `{{telefono}}` | The contact's phone | | `{{agente}}` | The acting user's name, from `resolveUsers` | | anything else | `resolveSnippetVariables({ contact, conversation, account })` in `createSendo` | App values win over the built-ins. A variable with no value (missing, blank, or the hook failed — it's logged) stays as `{{name}}` in the text and is listed in `unresolved`. The inbox composer highlights it and keeps **Enviar** disabled until it's filled in or removed. ```ts title="lib/sendo.ts" const sendo = createSendo({ adapter: memoryAdapter(), appSecret: process.env.SENDO_META_APP_SECRET ?? "", verifyToken: process.env.SENDO_VERIFY_TOKEN ?? "", resolveToken: (account) => process.env[account.tokenRef], resolveRole: () => Promise.resolve({ role: "admin" }), // App values for snippets, merged over the built-ins {{nombre}}, {{telefono}} and {{agente}}. // Leave a variable out (or blank) and it stays highlighted in the composer, blocking the send. resolveSnippetVariables: async ({ contact }) => { const values: Record = {}; const saldo = await findBalance(contact.appRef); if (saldo !== null) values.saldo = saldo; return values; }, }); ``` ```ts title="ts" // A team snippet (admins: `snippets.manage_tenant`); anyone can create `visibility: "personal"`. const saldo = await sendo.snippets.upsert( { shortcut: "saldo", body: "Hola {{nombre}}, tu saldo pendiente es de {{saldo}}. Vence el {{fecha_limite}}.", }, { actor: admin }, ); // The picker list: team snippets plus the actor's personal ones, usable on this number. const usable: Snippet[] = await sendo.snippets.list({ actor: admin, accountId }); const matches = searchSnippets(usable, "/sal"); // accent- and case-insensitive // Filled for one conversation. Variables without a value stay as written and are listed. const filled = await sendo.snippets.render(saldo.id, { actor: admin, conversationId }); // filled.text → "Hola María José, tu saldo pendiente es de Q 2,450.00. Vence el {{fecha_limite}}." // filled.unresolved → ["fecha_limite"] ``` `sendo.snippets`: `list({ actor, accountId? })`, `get(id, { actor })`, `upsert(input, { actor })`, `delete(id, { actor })` and `render(snippetId, { actor, conversationId })` → `{ text, unresolved }`. Someone else's personal snippet, or another tenant's, is `RecordNotFoundError`. The helpers `searchSnippets`, `findSnippetVariables`, `fillSnippetVariables` and `normalizeSearchText` (plus `SNIPPET_BODY_MAX_LENGTH` and `TAG_NAME_MAX_LENGTH`) are pure; in client components import them from the client-safe `@quami/sendo/snippets` subpath, as the inbox item does (importing values from `@quami/sendo` would bundle the server runtime, `ai`). In the inbox, typing `/` (or the ⚡ button) opens the picker: it searches shortcut and body ignoring accents and case, ↑ ↓ choose, Enter or Tab insert (through `render`), Escape closes. Everyone manages their personal snippets from **Respuestas rápidas** in the rail; admins also manage the team's. ### Tags The tenant's catalog: a `name` (1–64 characters, unique ignoring case) and a `color` from the fixed palette `TAG_COLORS` (`gray`, `red`, `orange`, `amber`, `green`, `teal`, `blue`, `violet`, `pink`). Every role can read it and tag the conversations they see (`sendo.inbox.tags.add` / `remove`); changing the catalog needs `tags.manage` (admins). ```ts title="ts" const urgente = await sendo.tags.upsert({ name: "urgente", color: "red" }, { actor: admin }); await sendo.inbox.tags.add(conversationId, urgente.id, { actor: admin }); const catalog = await sendo.tags.list({ actor: admin }); // every role can read the catalog const using = await sendo.tags.usage(urgente.id, { actor: admin }); // ask before deleting await sendo.tags.delete(urgente.id, { actor: admin }); // also removes it from every conversation ``` `sendo.tags`: `list({ actor })`, `upsert({ id?, name, color }, { actor })`, `delete(id, { actor })` and `usage(id, { actor })`. Deleting removes the tag from every conversation, so the inbox's **Etiquetas** sheet shows the usage count before it asks to confirm. The list filters by tag (any-match) and [metrics](https://sendo.quami.dev/docs/metrics) can be cut by tag. ## Agent pause `inbox.pauseAgent(id, paused, { actor })` (supervisors and admins) and `sendo.conversations.pauseAgent(id, paused, { by })` stop the AI on one conversation; `sendo.accounts.pauseAgent` stops it on a whole number. While paused, inbound messages are stored and count as unread, but no turn runs. A turn already running finishes. On resume the AI doesn't catch up: it answers from the next inbound message on. Every change emits `conversation.agent_paused { conversation, paused, by }`. ## Drafts With `mode: "draft"` on the agent, or `agentMode: "draft"` on the number (which wins), the agent runs its turn and its tools as usual, but stores the reply as a draft (`draft = true`, status `pending`) instead of sending it. - A newer turn replaces the pending draft; a reply from a person discards it. - `approveDraft(messageId, { actor, text? })` sends it as the agent, with `metadata.approvedBy` and `metadata.edited`. After the 24-hour window closed it throws `WindowClosedError` and the draft stays. - `discardDraft(messageId, { actor })` deletes it. - A draft proposed before a handoff stays for review with `handoffAfterDraft = true`. Tools run during the turn even in draft mode: a tool that writes data writes it before anyone approves the reply. ## Disconnected numbers When a conversation's number was disconnected, `reply`, `note` and `approveDraft` throw `AccountStateError` (`ACCOUNT_STATE`, `state: "inactive"`), and `InboxConversation.accountActive` is `false`. Reading the list and the thread keeps working. ## Usage and cost Every agent turn stores `tokensIn` and `tokensOut` on its reply or draft. `costCents` comes from the provider's reported cost (OpenRouter's `providerMetadata.openrouter.usage.cost`), else from the agent's `pricing: { inputPerMTok, outputPerMTok }` (USD per million tokens), else it's `null`. `sendo.inbox.thread` returns these fields to admins only. --- # Workflows URL: https://sendo.quami.dev/docs/workflows > Rules (business hours, routing, auto-assign, escalation, auto-close), the rules screen, rules sync and the every-minute tick route. Rules decide what happens to a conversation without anyone touching it: who answers outside business hours, which queue a new conversation goes to, who gets it, what happens when nobody replies, and when it closes. They are a small, declared set of five types, stored in `sendo_rules` per tenant (optionally per number), and admins edit them on the **Rules** screen without a deploy. ## Rule types | Type | Params | Runs | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | | `business_hours` | `timezone` (IANA), `schedule` (`mon`…`sun`, each a list of `["HH:MM", "HH:MM"]` ranges, end exclusive, `24:00` = midnight), `holidays?` (`YYYY-MM-DD`), `outsideMessage`, `appliesTo` | On every inbound message and on the tick | | `route` | `match: { channel?, account?, source?, keywords? }`, `action: { queue?, tags?, to: "agent" \| "human", reason? }` | On a new or reopened conversation; in order, first match wins | | `auto_assign` | `queue?` (omitted = any queue, `null` = no queue), `strategy: "least_open" \| "round_robin"` | When a conversation enters `human`, inside hours | | `escalate` | `afterMinutes` (1–10 080, open minutes only), `queue?`, `action: "reassign" \| "notify" \| "return_to_agent"` | On the tick | | `auto_close` | `afterDays` (1–365) without activity | On the tick | - **Business hours.** Outside hours the after-hours message is sent at most once per conversation per closed period, and only while the 24-hour window is open (it isn't a template; otherwise it's logged). Escalation and auto-assign timers don't count closed time. With `appliesTo: "human"` the AI keeps answering and the message goes to conversations with a person; with `"all"` the AI is held too. A rule for one number wins over the tenant-wide one. - **Routing.** Keywords match whole words in the first message, ignoring case and accents. `source` is the `[#code]` of a link (or `ad:` for a Click-to-WhatsApp ad, see [Links](https://sendo.quami.dev/docs/widgets#attribution)). The action sets the queue, adds tags (created in gray when missing) and hands off to a person with `by: "rule:"` when `to: "human"`. No match keeps today's behaviour. - **Every rule action is visible.** It adds a system line to the thread (`kind: "rule"`, e.g. `assigned` with `ruleKey` and `assignedTo`, stored as "Assigned to Ana López by rule «turno-cobranza»"; see [System events](https://sendo.quami.dev/docs/team-inbox#system-events)) and emits `conversation.status`, `conversation.assigned` or `rule.applied` with `by: "rule:"`; `notify` emits `escalation { conversation, rule, minutesWaiting }` for your app to turn into a notification. A rule's `key` is its name: unique per tenant, quoted in those system lines, and what `rules sync` upserts by. Every `params` object is validated by the zod schemas exported from `@quami/sendo` (`ruleParamsSchemas`, `parseRuleParams`); a stored rule that fails them is ignored by the engine and shown as an error on the screen. ## Roles and permissions `resolveRole` decides who can do what ([Team inbox](https://sendo.quami.dev/docs/team-inbox#roles)). Rules are admin only (`rules.manage`): `sendo.rules.*` throws `FORBIDDEN` for other roles, and the screen shows "You don't have access to Rules". | Capability | Agent | Supervisor | Admin | | ----------------------------------------------------------------------------- | ----- | ---------- | ----- | | See conversations assigned to self, plus unassigned ones in own queues | ✓ | ✓ | ✓ | | See all conversations in the tenant | | ✓ | ✓ | | Reply, step in, note, tag, approve drafts on visible conversations | ✓ | ✓ | ✓ | | Take an unassigned conversation, return it to the AI, close it | ✓ | ✓ | ✓ | | Reassign to someone else; pause the AI on a conversation | | ✓ | ✓ | | Reports and metrics | | ✓ | ✓ | | Numbers, rules, AI pause per number, tenant snippets, tag catalog, broadcasts | | | ✓ | The package checks are the source of truth for every adapter; Supabase RLS mirrors them as defense in depth. ## Rules in code Declare the rules your app ships with in a standalone module, so the CLI can load it without Next.js: ```ts title="lib/sendo-rules.ts" // lib/sendo-rules.ts: no Next.js imports, so `sendo rules sync --config` can load it. export const rules: RuleDefinition[] = [ { key: "Horario oficina", type: "business_hours", params: { timezone: "America/Guatemala", schedule: { mon: [ ["08:00", "13:00"], ["14:00", "18:00"], ], tue: [ ["08:00", "13:00"], ["14:00", "18:00"], ], wed: [ ["08:00", "13:00"], ["14:00", "18:00"], ], thu: [ ["08:00", "13:00"], ["14:00", "18:00"], ], fri: [ ["08:00", "13:00"], ["14:00", "18:00"], ], sat: [["09:00", "13:00"]], sun: [], }, holidays: ["2026-12-25", "2027-01-01"], outsideMessage: "¡Hola! Te respondemos el próximo día hábil a partir de las 8:00.", appliesTo: "human", // "all" also holds the AI agent outside hours }, }, { key: "Cobranza", type: "route", params: { match: { keywords: ["factura", "pago", "estado de cuenta"] }, action: { queue: "cobranza", tags: ["cobranza"], to: "human", reason: "Pregunta por pagos" }, }, }, { key: "Turno cobranza", type: "auto_assign", params: { queue: "cobranza", strategy: "round_robin" }, }, { key: "Sin respuesta 15 min", type: "escalate", params: { afterMinutes: 15, action: "reassign" }, }, { key: "Cierre automático", type: "auto_close", params: { afterDays: 3 } }, ]; ``` Then store them for a tenant: ```shell npx @quami/sendo rules sync --config lib/sendo-rules.ts --tenant ``` - Missing rules are inserted by `(tenant, key)` with origin `code`. Rules already there (for example, edited on the screen) are **never overwritten** unless you pass `--force`, which replaces type, params and number but keeps position and on/off. - Every rule is validated first; one invalid rule exits 1 and nothing is written. - The engine reads stored rules only, so run the sync after changing the file (for example, in your deploy step). Full flags in [CLI](https://sendo.quami.dev/docs/cli#rules-sync). ## The rules screen ```shell npx shadcn add @sendo/workflows ``` It adds the screen at `/inbox/rules` (`app/(protected)/inbox/rules`) and the tick route. Admins: - see the rules in evaluation order, each with its type, number, a one-line summary and who edited it last; **From code** marks rules from `rules sync`; - turn rules on and off, move them up and down (route rules are first-match-wins), and delete them (a code rule comes back on the next sync); - create rules of the five types and edit them with a typed form per type: the business-hours editor has the time zone, a weekly schedule with several ranges per day, holidays, the after-hours message and who it applies to. Forms validate with the package schemas before saving, and the server validates again. The schemas' English messages are mapped to the screen's language by issue code and field; - see an invalid stored rule as an error row with the problems listed, and fix it in the form. Every change records `updated_by` (the actor's `userId`). Before using the screen, implement `requireUser()` in `app/(protected)/inbox/rules/rules-config.ts` (it throws until you do) and configure `resolveRole`. Optionally implement `resolveUserNames` there to show "Edited by Ana". The screen renders in English or Spanish: the page passes `locale={sendoLocale}` from `lib/sendo-ui.ts`, and counts, lists and dates follow the optional `formatLocale`. Override single texts with `` or edit `components/sendo/workflows/strings.ts`; see [Languages](https://sendo.quami.dev/docs/languages). The screen calls `sendo.rules`, which you can also use directly: ```ts title="ts" // What the rules screen's server actions call. Every method needs `rules.manage` (admins). const list = await sendo.rules.list({ actor: admin }); // RuleView[]: StoredRule & { valid, error } await sendo.rules.upsert( { key: "Cierre automático", type: "auto_close", params: { afterDays: 7 } }, { actor: admin }, ); // by (tenant, key); records updatedBy = admin.userId const first = list[0]; if (first !== undefined) { await sendo.rules.toggle(first.id, false, { actor: admin }); await sendo.rules.reorder(list.map((rule) => rule.id).reverse(), { actor: admin }); } ``` ## The tick `sendo.maintenance.tick()` runs, in order and within a 50-second budget: after-hours bookkeeping (conversations held outside hours go back to the agent at opening), escalations, auto-close, broadcast batches, a billing re-check for numbers without a payment method (hourly) and the event prune (daily). It takes the lock `tick` (120 s), so two overlapping calls never double-send: the second returns `{ skipped: true }`. Call it **every minute**. ```ts title="ts" const report = await sendo.maintenance.tick(); // { now?, budgetMs? } for tests if (!report.skipped) { console.log(report.steps.escalations.count, report.steps.autoClose.count, report.durationMs); } ``` `workflows` ships the route `app/api/sendo/tick/route.ts`: `GET` or `POST`, `maxDuration = 60`, and `401` unless the request carries `Authorization: Bearer ` (Vercel's `CRON_SECRET` is accepted too). With no secret configured it always answers `401`. The response is the `TickReport` JSON. ```ts title="app/api/sendo/tick/route.ts" // Runs Sendo's periodic work: after-hours bookkeeping, escalations, auto-close, broadcast batches, // billing re-checks and the daily event prune (`sendo.maintenance.tick`). Call it every minute // from Vercel Cron (vercel.json) or pg_cron (`net.http_post`); see the workflows docs. import { createHash, timingSafeEqual } from "node:crypto"; import { sendo } from "@/lib/sendo"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const maxDuration = 60; /** `SENDO_CRON_SECRET`, plus Vercel's `CRON_SECRET` (Vercel Cron sends it as the bearer token). */ function cronSecrets(): string[] { return ["SENDO_CRON_SECRET", "CRON_SECRET"] .map((name) => process.env[name]?.trim() ?? "") .filter((value) => value !== ""); } const digest = (value: string) => createHash("sha256").update(value, "utf8").digest(); /** `Authorization: Bearer `, compared in constant time against every configured secret. */ function authorized(header: string | null, secrets: string[]): boolean { const token = /^Bearer (.+)$/i.exec(header?.trim() ?? "")?.[1]; if (token === undefined) return false; const given = digest(token); return secrets.map((secret) => timingSafeEqual(given, digest(secret))).includes(true); } async function tick(request: Request): Promise { const secrets = cronSecrets(); if (!authorized(request.headers.get("authorization"), secrets)) { if (secrets.length === 0) console.warn("[sendo] tick: set SENDO_CRON_SECRET to enable /api/sendo/tick"); return Response.json({ error: "unauthorized" }, { status: 401 }); } try { const report = await sendo.maintenance.tick(); return Response.json(report); } catch (error) { console.error("[sendo] tick failed", error); return Response.json({ error: "tick_failed" }, { status: 500 }); } } export const GET = tick; export const POST = tick; ``` ### Vercel Cron ```json title="vercel.json" { "crons": [{ "path": "/api/sendo/tick", "schedule": "* * * * *" }] } ``` Vercel Cron sends `Authorization: Bearer $CRON_SECRET`, so set `CRON_SECRET` in the project (or set both variables to the same value). Running every minute needs a plan that allows per-minute crons. ### Supabase pg_cron Enable `pg_cron` and `pg_net`, keep the secret in Vault and post to the route every minute: ```sql title="sql" -- Supabase: Database → Extensions → enable pg_cron and pg_net. Keep the secret in Vault. select vault.create_secret('', 'sendo_cron_secret'); select cron.schedule( 'sendo_tick', '* * * * *', $$ select net.http_post( url := 'https:///api/sendo/tick', headers := jsonb_build_object( 'Authorization', 'Bearer ' || ( select decrypted_secret from vault.decrypted_secrets where name = 'sendo_cron_secret' ), 'Content-Type', 'application/json' ), body := '{}'::jsonb, timeout_milliseconds := 60000 ); $$ ); -- Check the last runs, or stop it: select status, return_message, start_time from cron.job_run_details where jobid = (select jobid from cron.job where jobname = 'sendo_tick') order by start_time desc limit 5; select cron.unschedule('sendo_tick'); ``` Any other scheduler works the same way: one authenticated request per minute. The tick already prunes events once a day, so the separate prune job in [Retention](https://sendo.quami.dev/docs/retention) is optional once the tick runs. --- # Security URL: https://sendo.quami.dev/docs/security > Sensitive data, private storage, signed URLs, tokens and authorization inside tools. ## Where data lives Sendo has no server of its own. Messages, contacts, media and raw webhooks are stored only in your Supabase project (`sendo_*` tables and your Storage bucket), behind your RLS. Quami never receives them. For sensitive data (health, finance, minors) that means your app is the data controller for WhatsApp content too: apply the same policies you apply to the rest of your records. ## Private bucket and signed URLs - Inbound media is downloaded from Meta right away (Meta’s URLs expire in minutes) and stored at `{tenant_id | _}/{account_id}/{conversation_id}/{meta_media_id}.{ext}` in `SENDO_STORAGE_BUCKET`. - The bucket **must be private** (`public = false`). `doctor` checks that it exists, not its visibility, so check it in Supabase Studio. - The inbox only shows media through signed URLs valid for 300 seconds (`sendo.inbox.thread`). Don’t persist signed URLs or make objects public to “fix” previews. - Don’t add Storage policies that let browser clients read the bucket directly. ## Retention and encryption are yours Sendo prunes only `sendo_events` (raw webhook payloads, 30 days by default). It never deletes `sendo_messages` or `sendo_media`, and it doesn’t encrypt message content: - Decide how long conversations and files are kept and delete them on a schedule (see the example in [Retention](https://sendo.quami.dev/docs/retention#messages-media)). - Encryption at rest is your database’s and storage provider’s (Supabase encrypts disks and backups). If you need column-level encryption, add it in your app; note that `sendo_messages.text` holds a plain-text copy of the body for previews and search. - Raw payloads in `sendo_events.payload` contain message text and phone numbers; keep `eventRetentionDays` as short as your reprocessing needs allow. ## Authorization inside tools The agent’s context is limited to one contact and one conversation, and tool input is validated with zod before `run`. That doesn’t make the input trustworthy: a user can write anything, including instructions aimed at the model (prompt injection). So: - Authorize in `run` from the context Sendo gives you (`contact.appRef`, `account.tenantId`), never from ids the model passes in. - Handle `contact.appRef === null` (unlinked contacts): ask for an identifier and verify it, don’t guess. - Return only what this contact may see. Tool results go back to the model. - Give tools the narrowest scope: read-only where possible, no generic SQL. ```ts title="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, }); }, }); ``` ## Tokens and secrets - `sendo_accounts.token_ref` stores the **name** of an env var or secret. Tokens are resolved per call through `resolveToken(account)` and never written to the database. - Logs are redacted: the logger strips `Authorization` headers, `access_token` parameters, the app secret, the verify token and every token it resolved. `MetaApiError` messages are redacted too, and `toJSON()` omits `cause` and `stack`. `doctor` never prints a token. - `SUPABASE_SERVICE_ROLE_KEY` bypasses RLS: server only, never in a `NEXT_PUBLIC_*` variable. > **Important:** > > A token pasted into `sendo_accounts.token_ref` is a leaked token. Rotate it in Meta Business Settings and store the variable name instead. ## Webhook verification POSTs are checked with HMAC-SHA256 of the raw body against `X-Hub-Signature-256` using your app secret, compared in constant time; a bad signature returns 401 and stores nothing. The GET verify token is also compared in constant time. ## Database access RLS is enabled on every `sendo_*` table, with only SELECT policies through `sendo_can_access_tenant(tenant_id)` (default `false`). Events, locks and schema metadata are service-role only, and browser clients can’t write. Keep it that way: writes go through server actions that call your auth check ([Inbox](https://sendo.quami.dev/docs/inbox#auth)). --- # Adapters URL: https://sendo.quami.dev/docs/adapters > Where Sendo stores its data: Supabase, Drizzle or Prisma over your own Postgres, and what changes for access control and media. Every read and write in Sendo goes through a `SendoAdapter`. Pick the one that matches your stack; all of them pass the same contract suite (`runAdapterContract` from `@quami/sendo/adapters/contract`), so the pipeline, the inbox, rules, broadcasts and metrics behave the same on each. | Adapter | Database | Media by default | Access control | | ---------------------------------------------------- | ------------------------------------------------------------------ | -------------------------- | ----------------------------------------------- | | `supabaseAdapter` (`@quami/sendo/adapters/supabase`) | Supabase Postgres, through PostgREST with the service role | `supabaseStorage` (bucket) | Package role checks, plus RLS for browser reads | | `drizzleAdapter` (`@quami/sendo/adapters/drizzle`) | Any Postgres 15+ through `drizzle-orm` (node-postgres/postgres-js) | none: pass a `MediaStore` | Package role checks only | | `prismaAdapter` (`@quami/sendo/adapters/prisma`) | Any Postgres 15+ through `@prisma/client` | none: pass a `MediaStore` | Package role checks only | | `memoryAdapter` (`@quami/sendo/adapters/memory`) | Process memory (tests, demos) | in-memory map | Package role checks only | ## Supabase The default. Install the `schema` item (the SQL migrations) and pass a **service role** client: the `sendo_*` tables have RLS on and the helper functions are granted only to `service_role`. ```ts title="lib/sendo-adapter.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, }); ``` - Status ordering, locks, unread counts and the default-number swap run as Postgres functions from the migration (`sendo_update_message_status`, `sendo_mark_sent`, `sendo_acquire_lock`, `sendo_set_default_account`, …). - Metrics read the `sendo_metrics_*` views. - Media goes to the private `sendo-media` Storage bucket (`supabaseStorage`); `createSendo({ media })` replaces it, e.g. with `s3Storage`. - The inbox gets live updates from Supabase Realtime, filtered by the RLS hooks you replace (`sendo_can_access_tenant`, …). See [Security](https://sendo.quami.dev/docs/security). ## Drizzle `drizzleAdapter({ db, media? })` runs Sendo on your own Postgres (Neon, RDS, Cloud SQL, self-hosted) through Drizzle. `db` is a `drizzle-orm/node-postgres` or `drizzle-orm/postgres-js` database; `drizzle-orm` is an optional peer, so install it (and `pg` or `postgres`) in your app. ### Set up the database 1. Install the items: `npx shadcn add @sendo/schema @sendo/drizzle-schema`. 2. On plain Postgres (not Supabase), run `lib/db/sendo-postgres-prelude.sql` once. The migrations mention three Supabase roles (in read-only RLS policies) and `auth.uid()`; the prelude creates stand-ins that grant nothing: NOLOGIN roles `anon`, `authenticated`, `service_role` and an `auth.uid()` that returns null. It needs `CREATEROLE`; on Supabase it changes nothing. 3. Apply `20261010000000_sendo_v1.sql`, `20261011000000_sendo_v2.sql` and `20261013000000_sendo_v3.sql`, in that order, with your migration tool (for drizzle-kit, paste each into a `drizzle-kit generate --custom` migration). Postgres 15 or later (`nulls not distinct`). 4. Connect as the role that owns the tables (it needs to read and write every `sendo_*` table). The SQL migrations are the source of truth. `lib/db/sendo-schema.ts` mirrors them as `pgTable` definitions (a test checks table and column names, types and nullability against the SQL) so you can join Sendo's tables in your own typed queries. Keep it out of the `schema` list in `drizzle.config.ts` so drizzle-kit never generates or pushes migrations for `sendo_*` tables. ### Create the adapter ```ts title="lib/sendo-adapter.ts" import { drizzleAdapter } from "@quami/sendo/adapters/drizzle"; import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; // Sendo's storage on your own Postgres through Drizzle. Use `adapter` in lib/sendo.ts: // createSendo({ adapter, media, ... }) // `sendo doctor --config lib/sendo-adapter.ts` and `sendo mcp --config ...` read `adapter` // (and `media`) from this file. // // Already have a Drizzle `db`? Import it instead of creating a second pool: // export const adapter = drizzleAdapter({ db }); const pool = new Pool({ connectionString: process.env.DATABASE_URL }); /** The Drizzle database Sendo uses (node-postgres). */ export const db = drizzle({ client: pool }); /** Sendo's `SendoAdapter` over the `sendo_*` tables of `DATABASE_URL`. */ export const adapter = drizzleAdapter({ db }); // Media (images, audio, documents) needs a MediaStore; there is no Supabase Storage here. // For S3, R2 or any S3-compatible store (npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner): // import { S3Client } from "@aws-sdk/client-s3"; // import { s3Storage } from "@quami/sendo/storage/s3"; // export const media = s3Storage({ client: new S3Client({}), bucket: process.env.SENDO_MEDIA_BUCKET! }); ``` Then `createSendo({ adapter, media, ... })` in `lib/sendo.ts`. Already have a Drizzle `db`? Pass it instead of opening a second pool. `sendo doctor --config lib/sendo-adapter.ts` reads the same exports. ### How it works - Plain SQL over the `sendo_*` tables (no stored functions, so no `EXECUTE` grants are needed). Rows come back as JSON, so `node-postgres` and `postgres-js` give identical results. - The Supabase functions' work is done as single statements or transactions with the **same advisory locks** (`sendo_meta:`, `sendo_default_account:`): forward-only status updates and status placeholders (`updateStatus`), the placeholder merge in `markSent`, locks (`insert … on conflict … where expires_at <= clock_timestamp()`), unread increments, the default-number swap, and metadata and rule-state merges (`jsonb ||`). A Supabase app and a Drizzle worker can share one database. - `sendo.metrics.*` runs `METRICS_SQL` (the same queries as the `sendo_metrics_*` views) with bound parameters. - Failures the adapter doesn't map to a Sendo error throw `DrizzleAdapterError` (`operation`, Postgres `code`, `constraint`, `detail`). ## Prisma `prismaAdapter({ prisma, media? })` runs Sendo on your own Postgres through the `PrismaClient` your app already has (Prisma 5 or later; with Prisma 7, a client built with a driver adapter such as `@prisma/adapter-pg`). `@prisma/client` is an optional peer, so it comes from your app. ### Set up the database 1. Install the items: `npx shadcn add @sendo/schema @sendo/prisma-schema`. You get the SQL migrations in `supabase/migrations/`, plus `prisma/sendo.prisma` and `prisma/sendo-postgres-prelude.sql`. 2. On plain Postgres (not Supabase), run `prisma/sendo-postgres-prelude.sql` once (the same prelude as the [Drizzle](#drizzle-database) item: NOLOGIN stand-ins for `anon`, `authenticated`, `service_role` and an `auth.uid()` that returns null). 3. Apply the three `schema` files in order, each as its own Prisma migration: `npx prisma migrate dev --create-only --name sendo_v1`, paste `20261010000000_sendo_v1.sql` into the new `migration.sql`, then the same for v2 and v3, then `npx prisma migrate dev`. Postgres 15 or later. 4. Add the models: use Prisma's multi-file schema (`schema: "prisma"` in `prisma.config.ts`) so `prisma/sendo.prisma` is picked up, or paste its blocks into your `schema.prisma`. Then `npx prisma generate`. The SQL migrations are the source of truth. `sendo.prisma` mirrors them (models mapped with `@@map` / `@map` to the snake*case names, enums, relations, unique constraints and plain indexes); tests check it against the SQL (tables, columns, types, nullability) and check that `prisma migrate diff` from a migrated database to these models is empty, so Prisma Migrate never generates changes for `sendo*\*` tables. Prisma can't express the CHECK constraints, the partial and expression indexes, RLS, the functions or the metrics views; they stay in the SQL and Migrate leaves them alone. ### Create the adapter ```ts title="lib/sendo-adapter.ts" import { prismaAdapter } from "@quami/sendo/adapters/prisma"; import { s3Storage } from "@quami/sendo/storage/s3"; import { S3Client } from "@aws-sdk/client-s3"; import { prisma } from "@/lib/prisma"; // your PrismaClient /** Sendo's `SendoAdapter` over the `sendo_*` tables, through your Prisma client. */ export const adapter = prismaAdapter({ prisma }); /** Media (images, audio, documents): there is no Supabase Storage here. */ export const media = s3Storage({ client: new S3Client({}), bucket: process.env.SENDO_MEDIA_BUCKET! }); ``` Then `createSendo({ adapter, media, ... })` in `lib/sendo.ts`. `sendo doctor --config lib/sendo-adapter.ts` reads the same exports. Options: - `schema`: the Postgres schema that holds the `sendo_*` tables (default `public`), e.g. when you keep them in their own schema. - `transaction`: `{ maxWait, timeout }` in ms for the interactive transactions (default 10000 / 15000). ### How it works - Only `$queryRawUnsafe`, `$executeRawUnsafe` and `$transaction` are used, always with bound parameters (`$1`, `$2`, …; values are never spliced into the SQL). The adapter doesn't depend on your generated models, so renaming them or adding relations to your own models is safe. - Rows are read as JSON (`to_jsonb`), so Prisma 5, 6 and 7, with or without a driver adapter, return the same values. - The Supabase functions' work runs as single statements or interactive transactions with the **same advisory locks** as the SQL functions (`sendo_meta:`, `sendo_default_account:`): forward-only status updates and placeholders, the placeholder merge in `markSent`, locks, unread increments, the default-number swap, rule reordering, purge, and `jsonb ||` merges. A Supabase app and a Prisma worker can share one database. - `sendo.metrics.*` runs `METRICS_SQL` with bound parameters. - Failures the adapter doesn't map to a Sendo error throw `PrismaAdapterError` (`operation`, Postgres `code`). ## Shared: schema, media and access control ### Schema versions Every adapter reads the same `sendo_*` tables, created by the `schema` migrations. `sendo_schema_meta` records the installed version; the package warns at startup and `sendo doctor` fails `schema-version` when it is behind, naming the files to apply. New Sendo versions only add migrations: apply them with whatever tool you use. ### Media Media bytes never go through the adapter's tables, only through a `MediaStore`: `createSendo({ media })` wins, else the adapter's default (`adapter.storage`). The Supabase adapter defaults to its Storage bucket; Drizzle and Prisma have no default, so pass `media` (for example `s3Storage` from `@quami/sendo/storage/s3`, which works with S3, R2 and MinIO) or inbound images, audio and documents are stored as `failed`. See [API reference](https://sendo.quami.dev/docs/api-reference#media-store). ### Access control > **Note:** > > On the Drizzle and Prisma adapters, access control relies entirely on the package's role checks. RLS exists only on Supabase, and only for browser clients: your server connects as the table owner, which RLS does not restrict. - Every inbox, metrics, broadcast, rule, snippet and tag call takes an `actor` (`sendo.actor(userId, tenantId)`) and checks its role and capabilities, on every adapter. That is the security boundary on Drizzle and Prisma. - Never query `sendo_*` tables on behalf of a user without your own tenant and role check, and never expose them to browsers. - Without Supabase Realtime the inbox polls instead (list every 10 s, open thread every 3 s, paused while the tab is hidden). See [Inbox](https://sendo.quami.dev/docs/inbox). ### Your own adapter Implement `SendoAdapter` and run the contract suite in your tests; it is the definition of done. ```ts title="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; declare function truncateTables(): Promise; declare const databaseReachable: boolean; runAdapterContract("my-adapter", () => createMyAdapter(), { skip: databaseReachable ? false : "database unreachable", afterEach: truncateTables, }); ``` --- # Retention URL: https://sendo.quami.dev/docs/retention > Pruning sendo_events with pg_cron or a Vercel Cron route, your own policy for messages and media, exporting a number's history (v1 JSON), the numbers screen's Export conversations and Delete history (allowPurge), and purging a disconnected number. ## sendo_events (built in) Every webhook POST is stored raw in `sendo_events` so failed events can be reprocessed. Rows older than 30 days should be deleted. Pick one of the two options below; running both is harmless. ## Option A: pg_cron If the `pg_cron` extension is enabled when the `schema` migration runs (Supabase: **Database › Extensions**), the migration schedules a daily job: ```sql title="sql" -- Scheduled by the schema migration when pg_cron is installed: select cron.schedule('sendo_prune_events_daily', '17 3 * * *', 'select public.sendo_prune_events(30)'); ``` `sendo_prune_events(days)` deletes events received more than `days` ago and returns the count. It is service-role only. If you enable pg_cron later, or want another period: ```sql title="sql" -- Enabled pg_cron after applying the migration? Schedule it yourself: create extension if not exists pg_cron; select cron.schedule('sendo_prune_events_daily', '17 3 * * *', 'select public.sendo_prune_events(30)'); -- Change the retention, inspect or remove the job: select cron.unschedule('sendo_prune_events_daily'); select cron.schedule('sendo_prune_events_daily', '17 3 * * *', 'select public.sendo_prune_events(14)'); select * from cron.job where jobname = 'sendo_prune_events_daily'; ``` ## Option B: Vercel Cron Without pg_cron, call `sendo.maintenance.prune()` from a scheduled route. It deletes events older than `eventRetentionDays` (default 30) and returns the count; pass `{ olderThanDays }` to override. ```ts title="app/api/cron/sendo-prune/route.ts" // app/api/cron/sendo-prune/route.ts import { sendo } from "@/lib/sendo"; export const runtime = "nodejs"; export async function GET(request: Request) { // Vercel Cron sends `Authorization: Bearer $CRON_SECRET`. const secret = process.env.CRON_SECRET; if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) { return new Response("Unauthorized", { status: 401 }); } const deleted = await sendo.maintenance.prune(); // events older than eventRetentionDays return Response.json({ deleted }); } ``` ```json title="vercel.json" { "crons": [{ "path": "/api/cron/sendo-prune", "schedule": "17 3 * * *" }] } ``` Set `CRON_SECRET` in the Vercel project; Vercel sends it as a bearer token on cron requests. Any other scheduler (GitHub Actions, Supabase scheduled functions) can call the same route. ## Messages and media (your policy) Sendo never deletes `sendo_messages`, `sendo_media` or stored files. How long conversations are kept is your app’s decision (and, for health or financial data, often a legal one). A starting point that deletes files through the Storage API and then the messages (their `sendo_media` rows cascade): ```ts title="app/api/cron/sendo-message-retention/route.ts" // app/api/cron/sendo-message-retention/route.ts — YOUR policy for sendo_messages / sendo_media. import { createClient } from "@supabase/supabase-js"; const RETENTION_DAYS = 365; const BUCKET = process.env.SENDO_STORAGE_BUCKET ?? "sendo-media"; export async function GET(request: Request) { if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET ?? ""}`) { return new Response("Unauthorized", { status: 401 }); } const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL ?? "", process.env.SUPABASE_SERVICE_ROLE_KEY ?? "", { auth: { persistSession: false } }, ); const cutoff = new Date(Date.now() - RETENTION_DAYS * 86_400_000).toISOString(); // 1. Delete the stored files first (Storage API, not SQL on storage.objects). const { data: media, error } = await supabase .from("sendo_media") .select("storage_path") .lt("created_at", cutoff) .not("storage_path", "is", null) .limit(1000); if (error) throw error; const paths = (media as { storage_path: string }[]).map((row) => row.storage_path); if (paths.length > 0) await supabase.storage.from(BUCKET).remove(paths); // 2. Delete old messages; sendo_media rows cascade. const { count } = await supabase .from("sendo_messages") .delete({ count: "exact" }) .lt("created_at", cutoff); return Response.json({ files: paths.length, messages: count ?? 0 }); } ``` > **Note:** > > - Delete files before rows: once the `sendo_media` row is gone you lose the storage path. > - The sample handles up to 1,000 files per run; schedule it often enough or loop. > - Deleting messages also removes them from the inbox and from agent history. Archive first if you need an audit trail. See [Security](https://sendo.quami.dev/docs/security). ## Exporting a number’s history How long you keep conversations, and when you hand them over or delete them, is the operator’s decision: your app’s, not Sendo’s. A clinic may be legally required to keep records for years, or to delete them within a period. Sendo gives you the two tools and never runs either on its own: disconnecting a number keeps everything (read-only), and nothing is deleted until you call `purge`. `sendo.accounts.export(id, { actor })` returns the whole history of one number, connected or disconnected, as an `AsyncIterable`: the chunks of a single JSON document. Only admins can export (`numbers.manage`): any other actor gets `ForbiddenError` (`FORBIDDEN`) when you call it, before any chunk. A number of another tenant rejects the first chunk with `AccountNotFoundError`. `id` is the account id or the phone number id. ```ts title="app/api/numbers/[id]/export/route.ts" import { exportFileName } from "@quami/sendo"; import { sendo } from "@/lib/sendo"; export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const actor = await currentActor(); // sendo.actor(userId, tenantId) for the signed-in user const chunks = sendo.accounts.export(id, { actor }); // ForbiddenError for non-admins const iterator = chunks[Symbol.asyncIterator](); const first = await iterator.next(); // AccountNotFoundError before any byte is sent const account = await sendo.accounts.get(id); const encoder = new TextEncoder(); const body = new ReadableStream({ start(controller) { if (!first.done) controller.enqueue(encoder.encode(first.value)); }, async pull(controller) { const next = await iterator.next(); if (next.done) controller.close(); else controller.enqueue(encoder.encode(next.value)); }, }); return new Response(body, { headers: { "Content-Type": "application/json", "Content-Disposition": `attachment; filename="${exportFileName(account.displayPhone, new Date())}"`, }, }); } ``` Map `ForbiddenError` to 403 and `AccountNotFoundError` to 404 before streaming. The `numbers` registry item ships this route ready to use ([below](#numbers-screen)). From a terminal, `npx @quami/sendo accounts export --out history.json` writes the same document ([CLI](https://sendo.quami.dev/docs/cli#accounts-export)). - **Streamed.** Conversations are read 200 at a time with each thread paged inside, so memory stays flat for numbers with tens of thousands of messages. Write the chunks out as they come. - **A snapshot.** Everything with `createdAt <= exportedAt` (the moment the export started); messages and conversations written while it runs are left out. - **Media as references, not files.** Each media item has its `storageKey` and a `url` signed for 7 days when your media store can sign; otherwise `url` is `null` and you fetch it by `storageKey`. `missing: true` means the file was never stored (download pending or failed) or the store could not sign it, for example because your own retention job already deleted it. - **File name.** `exportFileName(displayPhone, exportedAt)` returns `sendo-export-50222345600-2026-10-12.json` (the number’s digits and the UTC date). ### Export format (v1) The document is `SendoExportDocumentV1` (exported from `@quami/sendo`). Dates are ISO 8601 strings; `metadata` is the stored message metadata, so system events keep `event`, `by` and their params. ```json title="sendo-export-50222345600-2026-10-12.json" { "format": "sendo-export", "version": 1, "exportedAt": "2026-10-12T18:04:11.000Z", "sendoVersion": "0.1.0", "account": { "id": "8f0c…", "tenantId": "0aa5680f-…", "phoneNumberId": "123456789012345", "wabaId": "900000000000123", "displayPhone": "+502 2234 5600", "label": "Torre Vista", "active": false, "onboardedVia": "embedded_signup", "createdAt": "2026-03-02T15:20:00.000Z" }, "contacts": [ { "id": "c1…", "waId": "50255124433", "profileName": "Rosa", "appRef": "Apto 4B", "optIn": { "marketing": true, "at": "2026-05-01T12:00:00.000Z" } } ], "tags": [{ "id": "t1…", "name": "cobranza", "color": "amber" }], "conversations": [ { "id": "v1…", "contactId": "c1…", "status": "closed", "assignedTo": "user-42", "tagIds": ["t1…"], "createdAt": "2026-10-01T09:00:00.000Z", "messages": [ { "id": "m3…", "direction": "out", "sender": "system", "senderRef": null, "type": "text", "text": "Sofia closed the conversation", "content": { "body": "Sofia closed the conversation" }, "internal": true, "draft": false, "status": "sent", "metadata": { "kind": "system", "event": "closed", "by": "user-42", "byName": "Sofia" }, "sentAt": "2026-10-02T10:00:00.000Z", "createdAt": "2026-10-02T10:00:00.000Z", "media": [] }, { "id": "m1…", "direction": "in", "sender": "contact", "senderRef": null, "type": "image", "text": "My receipt", "content": { "id": "300000000000001", "caption": "My receipt" }, "internal": false, "draft": false, "status": "received", "metadata": {}, "sentAt": "2026-10-01T09:00:00.000Z", "createdAt": "2026-10-01T09:00:01.000Z", "media": [ { "id": "md1…", "mimeType": "image/jpeg", "storageKey": "0aa5680f-…/8f0c…/v1…/300000000000001.jpg", "url": "https://…/sign/sendo-media/…?token=…", "missing": false } ] } ], "closedAt": "2026-10-02T10:00:00.000Z" } ] } ``` | Field | Notes | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account` | The number as stored, `active: false` once disconnected. No token or `tokenRef`. | | `contacts` | Every contact with a conversation on the number. `optIn.at` is when marketing consent was decided (`null` = never). | | `tags` | The tenant’s tag catalog; conversations point to it with `tagIds`. | | `conversations[].assignedTo` | The current assignee; past assignments are the `assigned` / `unassigned` system events in `messages`. | | `conversations[].closedAt` | When the last `closed` system event was written, for a closed conversation; else `null`. | | `conversations[].messages` | Newest first (the inbox thread order): contact and outbound messages, internal notes (`internal`, sender `human`), system events (`internal`, sender `system`) and pending agent drafts (`draft`). | | `messages[].media` | Zero or one item: references only, never the bytes. | New fields may be added in a later `version: 1` export; a breaking change bumps `version`. ## On the numbers screen `npx shadcn add @sendo/numbers` gives admins both tools in the UI, so nobody needs a terminal: - **Export conversations** on every number (connected or disconnected). It is a plain download link to `app/(protected)/inbox/numbers/export/[id]/route.ts` (`numbersConfig.links.export`, `/inbox/numbers/export/{id}`), so the browser shows its own progress for large files. The route calls `requireUser()`, resolves the actor and streams `sendo.accounts.export`: a user without `numbers.manage` gets **403** with no body, an unknown number or one of another tenant **404**, and an admin `sendo-export--.json` (`Content-Disposition: attachment`). - **Delete history** on disconnected numbers, **only when you opt in**: set `allowPurge: true` in `numbersConfig` (`app/(protected)/inbox/numbers/numbers-config.ts`). The page passes it to ``, and the `purge` server action checks it again, so the button and the action stay off together. The admin types the full number (digits are compared, so spaces and `+` don’t matter) before `sendo.accounts.purge` runs. ```ts title="app/(protected)/inbox/numbers/numbers-config.ts" export const numbersConfig: NumbersConfig = { // … allowPurge: true, // show "Delete history" on disconnected numbers links: { // … export: "/inbox/numbers/export/{id}", }, }; ``` The disconnect dialog tells admins that the history stays and can be exported. Whether you turn on **Delete history**, and how long you keep a number’s conversations, is your retention duty as the operator of the app (a clinic may have to keep records for years, or delete them on request); Sendo only provides the tools. ## Deleting a number’s history (purge) `sendo.accounts.purge(id)` is separate from export and only works on a **disconnected** number: it deletes the account, its contacts, conversations, messages, media rows and templates, then the media files. An active number throws `AccountStateError` (`ACCOUNT_STATE`) and nothing is deleted, so the order is always `disconnect`, export if you need a copy, then `purge`. There is no undo. Whether and when to purge is your retention policy ([Deleting an organization’s data](https://sendo.quami.dev/docs/numbers-and-tenants#purge)). --- # Multi-number URL: https://sendo.quami.dev/docs/multi-number > Several numbers and tenants on one deployment: routing, accounts and access. ## Accounts and tenants One deployment can serve many WhatsApp numbers. Each number is a `sendo_accounts` row with its `phone_number_id`, `waba_id`, its own token env var (`token_ref`), a `timezone` and an optional `tenant_id` (for example one number per clinic or building). Every contact, conversation and message copies the account’s `tenant_id`. - Contacts are unique per account and phone: the same person writing to two tenants’ numbers becomes two contacts, each with its own history. - `sendo.accounts.disconnect(id)` stops routing and sending for a number without deleting its data ([Numbers and tenants](https://sendo.quami.dev/docs/numbers-and-tenants#disconnect)). ## Adding numbers ```shell # .env.local / deployment env SENDO_TOKEN_CLINICA_NORTE=... SENDO_TOKEN_CLINICA_SUR=... npx @quami/sendo accounts add --phone-number-id 111111111111111 --token-env SENDO_TOKEN_CLINICA_NORTE \ --tenant-id 6f1c0c3e-0000-4000-8000-000000000001 --timezone America/Guatemala --yes npx @quami/sendo accounts add --phone-number-id 222222222222222 --token-env SENDO_TOKEN_CLINICA_SUR \ --tenant-id 6f1c0c3e-0000-4000-8000-000000000002 --yes ``` Numbers in different WABAs can use different tokens; numbers in the same WABA can share one env var. Then subscribe each WABA’s webhook ([Meta setup](https://sendo.quami.dev/docs/meta-setup#webhook)) and run `doctor`, or `doctor --account ` for one. ## Inbound routing All numbers share one webhook URL. Each change in a payload carries `metadata.phone_number_id`, which is looked up in `sendo_accounts`: - Active match: processed for that account and tenant. - No match or inactive: the event is marked `unrouted`, a warning is logged, the `event.unrouted` event fires, and nothing else is written. Add the account and `sendo.events.reprocess(id)` it. ```ts title="ts" // Replies to inbound messages go out from the number that received them. sendo.on("message", async ({ account, reply }) => { console.log("received on", account.displayPhone, "tenant", account.tenantId); await reply.text("Recibido"); }); ``` ## Sending from a number With exactly one active account, sends use it by default. With more than one, every `sendo.send.*` and `sendo.templates.sync` call must pass `tenant` (the organization’s default number, see [Numbers and tenants](https://sendo.quami.dev/docs/numbers-and-tenants#tenant)) or `account`: the Sendo account id, the Meta phone number id, or the `Account` object. Otherwise the call throws `AmbiguousAccountError` (or `AccountNotFoundError` for an unknown or inactive one). Replies (`reply`, agents, `sendo.inbox.reply`) always go out from the conversation’s own number. ```ts title="ts" // With more than one active account, every send names its account: // the sendo account id (uuid), the Meta phone_number_id, or the Account object. await sendo.send.template(patientPhone, "recordatorio_cita", { account: tenantPhoneNumberId, language: "es", variables: ["Ana", "mañana 10:00"], }); await sendo.send.text(patientPhone, "Te esperamos.", { account: tenantPhoneNumberId }); await sendo.templates.sync({ account: tenantPhoneNumberId }); ``` ```ts title="ts" try { await sendo.send.text(patientPhone, "Hola"); // no account } catch (error) { if (error instanceof AmbiguousAccountError) console.error(error.candidates); // account ids } ``` ## Agents per number An agent’s `accounts` option is a list of account ids / phone number ids or a predicate; `tenants` filters by organization the same way, and both must match ([one agent for many tenants](https://sendo.quami.dev/docs/numbers-and-tenants#agents)). Only one agent may match each account: overlapping lists throw `ConfigError` at registration; overlaps found later (predicates, new accounts) are logged and no agent answers on that account until you fix them. The prompt uses the account’s `timezone` unless the agent sets one. ```ts title="ts" const model = "openai/gpt-5-mini"; const surTenantId = "6f1c0c3e-0000-4000-8000-000000000002"; sendo.agent({ name: "norte", accounts: ["111111111111111"], model, instructions: "…" }); sendo.agent({ name: "sur", accounts: (account) => account.tenantId === surTenantId, model, instructions: "…", }); ``` Full agent options: [API reference](https://sendo.quami.dev/docs/api-reference#agents). ## Access per tenant - Browser reads (inbox realtime) are filtered by `sendo_can_access_tenant(tenant_id)`: implement it with your membership table ([Quickstart step 4](https://sendo.quami.dev/docs/quickstart#migration)). - Server actions pass the user’s `tenantId` to `sendo.inbox.*`, and `linkContact` / `resolveHandoffTargets` receive the `account` so you can scope lookups by `account.tenantId`. --- # Channels URL: https://sendo.quami.dev/docs/channels > WhatsApp today; what changes for Instagram and Messenger later, and what doesn't. Sendo ships one channel: **WhatsApp** (Cloud API). Instagram and Messenger are not built yet. The schema, the webhook and the parser layout are already shaped for them, so adding them later is new code in the package, not a migration in your app. ## What already works for every channel - **Schema.** `sendo_accounts`, `sendo_contacts` and `sendo_conversations` carry a `channel` column, and contacts are identified by `channel_user_id` (the phone is optional). Schema v1 was rewritten with this before the first release, so a new channel needs no migration. - **Webhook dispatch.** `sendo.handleWebhook` verifies `X-Hub-Signature-256` once for every Meta product, stores the event, and dispatches on the body’s `object` to that channel’s parser (`whatsapp_business_account` today). - **Unknown products are kept, not lost.** A body for `instagram` or `page` before those channels exist is stored as an `unrouted` event (`event.unrouted`) and answered with 200, so Meta doesn’t retry forever. You can reprocess it later with `sendo.events.reprocess(id)`. - **The inbox, team features, rules and metrics** read the `channel` of each conversation; the list shows a channel badge on the avatar. ## What changes per channel | | WhatsApp | Instagram / Messenger (later) | | ------------------------------ | --------------------------------------------------------------------------------------------------- | ---------------------------------------------- | | Contact id | Phone (`wa_id`) and `channel_user_id` | Page-scoped id only (no phone) | | Starting a conversation | Approved templates outside the 24-hour window | Meta’s messaging-window rules for each product | | Links | `wa.me` links and QR ([Widgets](https://sendo.quami.dev/docs/widgets)) | `ig.me` / `m.me`, added when the channel ships | | Billing | Meta bills the WhatsApp Business account per delivered message ([Meta pricing](https://sendo.quami.dev/docs/meta-pricing)) | Not billed per message | | Coexistence with the phone app | [Coexistence](https://sendo.quami.dev/docs/coexistence) | Not applicable | ## One person, several channels A person who writes on WhatsApp and later on Instagram becomes **two contacts and two conversations**, joined by the same `app_ref` (the record in your app that `linkContact` returns). Merging them into one thread is a UI decision left for later; your app already knows they are the same customer. ## Why not ship links for channels that don't exist yet The link generator (`sendo.links.create`) and the chat bubble are WhatsApp-only on purpose: a link to a channel whose messages Sendo can’t receive would be a broken promise. --- # Numbers and tenants URL: https://sendo.quami.dev/docs/numbers-and-tenants > Send as an organization with { tenant }, default numbers, and the sendo.accounts API: add, label, pause, disconnect, purge. A SaaS connects one or more WhatsApp numbers per organization (tenant). Sendo resolves which number to use from the tenant, so your code passes `{ tenant }` instead of account ids. For the data model and inbound routing, see [Multi-number](https://sendo.quami.dev/docs/multi-number). ## Sending as a tenant Every account-scoped call takes `tenant` next to `account`: all of `sendo.send.*`, `sendo.templates.sync` and `sendo.templates.list`. `tenant: null` means the accounts without a tenant. ```ts title="ts" // Goes out from the organization's default number. No account ids to pass around. await sendo.send.template(patientPhone, "recordatorio_cita", { tenant: tenantId, language: "es", variables: ["Ana", "mañana 10:00"], }); await sendo.send.text(patientPhone, "Te esperamos.", { tenant: tenantId }); await sendo.templates.sync({ tenant: tenantId }); // every active number of the tenant ``` - `templates.sync({ tenant })` pulls the templates of every active number of the tenant; `templates.list({ tenant })` lists them. - Replies (`reply`, agents, `sendo.inbox.reply`) always go out from the conversation’s own number; they don’t need either option. ## Resolution order | You pass | Sendo uses | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account` | That account (id, phone number id or `Account`). With `tenant` too, an account of another tenant throws `AccountNotFoundError`. | | `tenant` | The tenant’s **default** active number; without a default, its only active number. Several and no default: `AmbiguousAccountError`. None: `AccountNotFoundError`. | | neither | The only active number of the deployment; several: `AmbiguousAccountError`, whose message suggests passing `{ tenant }`. | `AmbiguousAccountError` carries `candidates` (only that tenant’s account ids when `tenant` was given) and `tenantId`. `AccountNotFoundError` carries `account` and `tenantId`. ```ts title="ts" try { await sendo.send.text(patientPhone, "Hola", { tenant: tenantId }); } catch (error) { // Two or more active numbers in the tenant and none is the default. if (error instanceof AmbiguousAccountError) console.error(error.tenantId, error.candidates); } ``` ## Default number Each tenant has at most one default number (the accounts without a tenant count as one tenant); the database enforces it with a unique index. - The first number added to a tenant becomes its default (`accounts.add`, Embedded Signup). - `sendo.accounts.setDefault(id)` moves the default atomically: the old one is unset and the new one set in one transaction, so concurrent calls never leave two defaults. - When the default is disconnected and exactly one active number is left, that one becomes the default; otherwise the tenant has none until you pick one. - Each change emits `account.default`. ## sendo.accounts | Method | Does | | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `list({ tenant?, includeInactive? })` | Accounts, oldest first. Disconnected ones only with `includeInactive`. | | `get(id)` | One account, connected or not. | | `add(input)` | Registers a number (the code version of `sendo accounts add`). A number disconnected in the same tenant is reconnected with its id and history. | | `setDefault(id)` | Makes it the tenant’s default. Disconnected numbers throw `AccountStateError`. | | `setLabel(id, label)` | Name shown in the inbox (e.g. "Recepción"); blank clears it. | | `pauseAgent(id, paused, { by? })` | Stops (or resumes) AI turns on every conversation of the number. | | `setAgentMode(id, "auto" \| "draft" \| null)` | Per-number agent mode; `null` keeps the agent definition’s own mode. | | `disconnect(id, { by? })` | See [Disconnecting](#disconnect). | | `purge(id)` | See [Deleting an organization’s data](#purge). | | `refreshBilling(id)` | Re-reads the payment method on Meta (Embedded Signup). | | `completeEmbeddedSignup(input)` | Finishes Embedded Signup from the numbers screen. | Every `id` can also be the Meta phone number id. An unknown one throws `AccountNotFoundError`. These are server-side methods: check the actor’s `numbers.manage` capability in your server actions before calling them. ```ts title="ts" const numbers = await sendo.accounts.list({ tenant: tenantId, includeInactive: true }); const added = await sendo.accounts.add({ tenantId, phoneNumberId: "333333333333333", wabaId: "444444444444444", displayPhone: "+502 5555 3333", tokenRef: "SENDO_TOKEN_CLINICA_ESTE", // env var NAME, never the token label: "Recepción", }); // the tenant's first number becomes its default await sendo.accounts.setDefault(added.id); // atomic swap within the tenant await sendo.accounts.setLabel(added.id, "Recepción principal"); await sendo.accounts.pauseAgent(added.id, true, { by: "user_123" }); await sendo.accounts.setAgentMode(added.id, "draft"); // null = the agent's own mode ``` ## Disconnecting `disconnect(id)`: 1. sets `active = false` and clears `is_default`; inbound webhooks for the number become `unrouted` and sends throw `AccountNotFoundError`; 2. promotes the tenant’s only remaining active number to default, if there is exactly one; 3. for Embedded Signup numbers, unsubscribes your app from the WABA (`DELETE /{waba_id}/subscribed_apps`), best effort: a failure is logged, never thrown; 4. calls your `deleteToken(tokenRef)` hook, if configured (a failure is logged); 5. keeps contacts, conversations and messages, so the inbox still shows the history, read-only (out of the inbox’s default “All active numbers” view; “Show disconnected”, picking the number or a search brings them back); 6. emits `account.disconnected` and, when the default changed, `account.default`. Connecting the same number again in the same tenant reuses the row: same account id, the old conversations come back, with the new `tokenRef`. Connecting it from another tenant throws `DuplicateAccountError` (`ACCOUNT_EXISTS`). > **Note:** > > `deleteToken` receives every disconnected account’s `tokenRef`, including env var names of numbers added by hand. Ignore refs your hook didn’t create; for example, a Vault-backed hook deletes only `vault:` refs. ## Deleting an organization’s data `purge(id)` deletes a **disconnected** account and, by cascade, its contacts, conversations, messages, media rows and templates, then deletes the media objects from storage. It throws `AccountStateError` (`ACCOUNT_STATE`) on an active account and deletes nothing, so the order is always `disconnect` then `purge`. There is no undo; when to call it is your app’s decision. Export the history first if you need a copy ([Retention](https://sendo.quami.dev/docs/retention#export)). Disconnecting never deletes. On the numbers screen (`@sendo/numbers`) admins get **Export conversations** on every number, and **Delete history** on disconnected ones only when the app sets `numbersConfig.allowPurge = true` (off by default; the admin types the full number to confirm). See [Retention](https://sendo.quami.dev/docs/retention#numbers-screen). ```ts title="ts" // Stops sending and routing, keeps the history readable, promotes the tenant's // only remaining number to default, calls deleteToken(tokenRef). await sendo.accounts.disconnect(accountId, { by: "user_123" }); // Later, when the organization asks for its data to be deleted: try { await sendo.accounts.purge(accountId); // rows + media objects, no undo } catch (error) { if (error instanceof AccountStateError) console.error("Disconnect it first"); } ``` ## One agent for many tenants A SaaS with 100 organizations doesn’t declare 100 agents. Register one definition and read the tenant from the conversation’s number: `instructions` and every tool receive `account`, and `account.tenantId` is the organization. - `tenants?` is a list of tenant ids (`null` is the accounts without a tenant) or a predicate `(tenantId) => boolean`. Default: every tenant. - It is combined with `accounts?`: an agent answers on a number only when **both** match. - Only one agent may match a number. When both definitions use lists, Sendo checks `accounts` and `tenants` together at registration and throws `ConfigError` on an overlap; overlaps through predicates are logged when a message arrives and no agent answers on that number until you fix them. - `mode?: "auto" | "draft"` is the definition’s default; a number’s own mode (`accounts.setAgentMode`) wins. - No turn runs while the number (`accounts.pauseAgent`) or the conversation is paused. Inbound messages are still stored and still count as unread and open the 24-hour window. A turn already running when the pause is set finishes. ```ts title="ts" // One definition for every organization: the tenant comes from the conversation's number. const clinics = new Map([ ["6f1c0c3e-0000-4000-8000-000000000001", { name: "Clínica Norte", hours: "8:00 a 17:00" }], ["6f1c0c3e-0000-4000-8000-000000000002", { name: "Clínica Sur", hours: "9:00 a 13:00" }], ]); sendo.agent({ name: "recepcion", tenants: (tenantId) => tenantId !== null && clinics.has(tenantId), // or a list of tenant ids model: "openai/gpt-5-mini", instructions: ({ account }) => { const clinic = clinics.get(account.tenantId ?? ""); return `Eres la recepción de ${clinic?.name ?? "la clínica"}. Horario: ${clinic?.hours ?? "consultar"}.`; }, }); // A different agent for one organization: lists that overlap throw ConfigError at registration. sendo.agent({ name: "recepcion-pediatria", tenants: ["6f1c0c3e-0000-4000-8000-000000000003"], model: "openai/gpt-5-mini", instructions: "Eres la recepción de la clínica pediátrica.", }); ``` ## Events | Event | Payload | When | | ---------------------- | ------------------------------------- | ----------------------------------------------------------------- | | `account.default` | `{ tenantId, account, previous }` | The tenant’s default changed; `account` is null when it has none. | | `account.disconnected` | `{ account, by }` | A number was disconnected. | | `account.billing` | `{ account, billingReady, previous }` | The payment-method state of a number changed. | ```ts title="ts" sendo.on("account.default", ({ tenantId, account, previous }) => { console.log( "default of", tenantId, "is now", account?.displayPhone ?? "none", "was", previous?.id, ); }); sendo.on("account.disconnected", ({ account, by }) => { console.log(account.displayPhone, "disconnected by", by); }); ``` ## Config | Option | Type | Notes | | ------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `storeToken` | `({ account, token }) => Promise` | Stores an Embedded Signup token, returns the `tokenRef`. Required for Embedded Signup. | | `deleteToken` | `(tokenRef) => Promise` | Called by `disconnect`. | | `meta` | `{ appId?, configId? }` | Your Meta app, for Embedded Signup. | | `coexistence` | `{ pauseAgentOnPhoneReply?, importHistory? }` | Numbers shared with the WhatsApp Business app ([Coexistence](https://sendo.quami.dev/docs/coexistence)). Defaults `true` and `false`. | ## From the terminal ```shell npx @quami/sendo accounts list [--tenant-id ] [--json] npx @quami/sendo accounts set-default npx @quami/sendo accounts disconnect ``` Details in [CLI](https://sendo.quami.dev/docs/cli#accounts-manage). --- # Embedded Signup URL: https://sendo.quami.dev/docs/embedded-signup > Let an admin connect a WhatsApp number from your app, plus the billing state: what Meta requires, completeEmbeddedSignup, errors and refreshBilling. Embedded Signup lets an admin connect a WhatsApp number from inside your app: Meta’s popup creates or picks the WhatsApp Business account and number, and Sendo finishes the setup server-side. Nobody copies ids or tokens by hand. ## What Meta requires first > **Warning:** > > Embedded Signup runs on **your** Meta app, so your business becomes a Meta **Tech Provider**. Before the button works for customers, Meta requires: > > - **Business verification** of your Meta Business portfolio. > - **App review** approving `whatsapp_business_management` and `whatsapp_business_messaging` (Advanced access). > - Accepting the **Tech Provider** terms and creating an Embedded Signup configuration (the `configId`). > > This takes **days to weeks**, depending on Meta’s review queue. The manual path ([Meta setup](https://sendo.quami.dev/docs/meta-setup) and `sendo accounts add`) needs none of it: a system user token on your own WABA works the same day. Until the review passes, only people with a role on your Meta app can complete the popup; everyone else gets a permissions error (`META_PERMISSION_MISSING`, below). ## How it works 1. The admin taps **Connect WhatsApp** on the numbers screen. The Facebook JS SDK opens Meta’s popup with `NEXT_PUBLIC_SENDO_META_APP_ID` and `NEXT_PUBLIC_SENDO_META_CONFIG_ID`. 2. Meta returns a single-use `code` and, from the session-info event, `waba_id` and `phone_number_id`. For a number that stays in the WhatsApp Business app, start the flow with `featureType: "whatsapp_business_app_onboarding"` ([Coexistence](https://sendo.quami.dev/docs/coexistence)). 3. Your server action calls `sendo.accounts.completeEmbeddedSignup({ code, tenantId, wabaId, phoneNumberId, coexistence?, pin?, label? })`. The `numbers` registry item ships the button, the action and a Supabase Vault `storeToken`. Requirements before the button: `NEXT_PUBLIC_SENDO_META_APP_ID`, `NEXT_PUBLIC_SENDO_META_CONFIG_ID`, `SENDO_META_APP_SECRET` and the `storeToken` / `deleteToken` hooks. ## The numbers screen `npx shadcn add @sendo/numbers` installs the numbers screen at `/inbox/numbers`, in English or Spanish (`sendoLocale` in `lib/sendo-ui.ts`, see [Languages](https://sendo.quami.dev/docs/languages)): connect (new number or the WhatsApp Business app flavour), label, default, agent pause and mode, **I added it, check again** (“Ya lo agregué, verificar” in Spanish), reconnect, disconnect (typing the last 4 digits), **Export conversations** and, when you opt in, **Delete history** ([Retention](https://sendo.quami.dev/docs/retention#numbers-screen)). Only `admin` (`numbers.manage`) changes or exports numbers; other roles get a read-only list, or nothing with `numbersVisibleToNonAdmins: false`. - Implement `requireUser()` in `app/(protected)/inbox/numbers/numbers-config.ts` and `resolveRole` in `createSendo`. - Apply `supabase/migrations/20261012000000_numbers_vault_tokens.sql` and uncomment `...createVaultTokenStore(supabaseAdmin)` and `meta` in `lib/sendo.ts`. Tokens are stored as `vault:`; `resolveToken` reads those from Vault and every other `tokenRef` from `process.env`. Single-tenant apps can skip Vault and add their number with `sendo accounts add --token-env`. - Add your HTTPS domain to the Meta app’s Facebook Login for Business settings (allowed JavaScript SDK domains). - A `PIN_CONFLICT` spends the code, so the screen asks for the PIN and opens Meta’s popup again. - **Invalid token · reconnect:** when the screen loads it re-checks every connected number with `refreshBilling`; Meta error 190 emits `account.error`, and those numbers are marked. Nothing extra is stored. Turn it off with `checkTokensOnLoad: false`. ## Configuration `completeEmbeddedSignup` throws `ConfigError` without `storeToken`, `meta.appId` or the app secret. The business token goes to your secret store through `storeToken`; Sendo only saves the reference it returns as `tokenRef`, and your `resolveToken` reads it back. ```ts title="lib/sendo.ts" // Keep the business token in your secret store; Sendo only saves the returned reference. const storeToken: StoreToken = async ({ account, token }) => { const id = await saveSecret( `sendo/${account.tenantId ?? "default"}/${account.phoneNumberId}`, token, ); return `vault:${id}`; }; // Called on disconnect (and to undo a failed signup). Ignore refs you didn't create. const deleteToken: DeleteToken = async (tokenRef) => { if (tokenRef.startsWith("vault:")) await deleteSecret(tokenRef.slice("vault:".length)); }; export const sendo = createSendo({ adapter: memoryAdapter(), // your adapter appSecret: process.env.SENDO_META_APP_SECRET ?? "", verifyToken: process.env.SENDO_VERIFY_TOKEN ?? "", resolveToken: (account) => account.tokenRef.startsWith("vault:") ? readSecret(account.tokenRef.slice("vault:".length)) : process.env[account.tokenRef], storeToken, deleteToken, meta: { appId: process.env.NEXT_PUBLIC_SENDO_META_APP_ID, configId: process.env.NEXT_PUBLIC_SENDO_META_CONFIG_ID, }, }); ``` ## What completeEmbeddedSignup does On Graph `GRAPH_API_VERSION`: 1. Exchanges the code: `GET /oauth/access_token?client_id&client_secret&code` → the business token. 2. Subscribes your app to the WABA’s webhooks: `POST /{waba_id}/subscribed_apps`. 3. Registers the number: `POST /{phone_number_id}/register` with the two-step PIN. Skipped for coexistence numbers. The PIN is the one the admin typed or a random 6 digits; it is never stored (two-step verification can be reset from WhatsApp Manager). 4. Reads `display_phone_number` and `verified_name`. 5. Calls `storeToken({ account, token })` → `tokenRef`. 6. Inserts the account with `onboarded_via = "embedded_signup"`, or reconnects the same row when the number was disconnected in the same tenant ([Numbers and tenants](https://sendo.quami.dev/docs/numbers-and-tenants#disconnect)). 7. Makes it the tenant’s default if it is the tenant’s first number. 8. Checks billing (below). A number already connected (in any tenant), or disconnected in another tenant, throws `DuplicateAccountError` before the code is used. If steps 1–5 fail, nothing is stored. If anything fails after `storeToken`, Sendo calls `deleteToken(tokenRef)`, unsubscribes the app from the WABA (best effort, skipped when another connected number uses the same WABA) and leaves no account row. The token never appears in logs, errors, rows or events. ## Errors Branch on `code`; each one has a message your UI can show. | `code` | Class | Meaning | Suggested message | | ------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | | `SIGNUP_CODE_EXPIRED` | `EmbeddedSignupError` | The code expired or was already used. | “El enlace con Meta expiró, intenta de nuevo.” | | `META_PERMISSION_MISSING` | `EmbeddedSignupError` | The app lacks a permission (app review not done) or access to the WABA/number. | Link to [the requirements](#requirements). | | `PIN_CONFLICT` | `EmbeddedSignupError` | The number already has a two-step PIN and it was missing or wrong (or too many attempts). | Ask for the existing PIN and retry with `pin`. | | `SIGNUP_FAILED` | `EmbeddedSignupError` | Anything else; `step` says where (`exchange_code`, `subscribe_app`, `register_phone`, `read_phone`, `store_token`, `save_account`). | Generic retry. | | `ACCOUNT_EXISTS` | `DuplicateAccountError` | The number is connected in another organization. | “Este número ya está conectado en otra organización.” | | `CONFIG_ERROR` | `ConfigError` | Missing `storeToken`, `meta.appId` or app secret. | A setup problem, not a user one. | `EmbeddedSignupError` also carries `step`, `metaCode`, `subcode`, `fbtraceId` and `details` from Meta. A popup the admin closed never reaches the server: show a neutral toast and create nothing. ```ts title="app/(protected)/numbers/actions.ts" type ConnectResult = { ok: true; account: Account } | { ok: false; message: string }; /** Server action called by the "Connect WhatsApp" button with what Meta returned. */ export async function connectWhatsApp(input: { tenantId: string; code: string; wabaId: string; phoneNumberId: string; coexistence?: boolean; pin?: string; }): Promise { // Check your actor's `numbers.manage` capability first. try { const account = await sendo.accounts.completeEmbeddedSignup(input); return { ok: true, account }; } catch (error) { if (error instanceof DuplicateAccountError) { return { ok: false, message: "Este número ya está conectado en otra organización." }; } if (error instanceof EmbeddedSignupError) { const messages: Record = { SIGNUP_CODE_EXPIRED: "El enlace con Meta expiró, intenta de nuevo.", META_PERMISSION_MISSING: "Tu app de Meta aún no tiene los permisos necesarios. Revisa sendo.quami.dev/docs/embedded-signup.", PIN_CONFLICT: "Este número tiene un PIN de verificación en dos pasos. Escríbelo para continuar.", SIGNUP_FAILED: "No pudimos conectar el número. Intenta de nuevo.", }; return { ok: false, message: messages[error.code] }; } throw error; } } ``` ## Billing state As a Tech Provider you don’t pay for your customers’ messages: each customer adds a payment method to their own WABA in Meta. Until they do, sends fail. `account.billingReady` tracks it: | Value | Shown as | Set by | | ------- | ---------------------------------- | ----------------------------------------------------------------------- | | `true` | Connected | Meta reports a payment method. | | `false` | Connected · payment method missing | Meta reports none, or a send failed with error `131042`. | | `null` | Connected · payment status unknown | Not checked yet, or Meta didn’t say. The first failed send resolves it. | - `sendo.accounts.refreshBilling(id)` reads the WABA’s funding field (`GET /{waba_id}?fields=primary_funding_id`): a funding id → `true`, none → `false`, a Meta error → the value is kept. It always sets `billingCheckedAt` and emits `account.billing` when the value changed. Wire it to an **I added it, check again** button. - Sends never skip Meta because of `billingReady = false`: Meta is the source of truth. When Meta answers `131042`, Sendo sets `billingReady = false`, emits `account.billing` and rethrows the `MetaApiError` with `hint: "billing_not_ready"` (“Este número aún no tiene método de pago en Meta”). - `sendo doctor` adds `account::billing`, a warning (not a failure) with a Meta Business Settings link when the value is `false` or `null`. `MetaApiError.hint` is one of `billing_not_ready` (131042), `token_invalid` (190), `window_closed` (131047), `rate_limited` (4, 17, 32, 613, 80007, 130429, 131056), or `null`. ```ts title="ts" export async function sendReminder(tenantId: string, phone: string): Promise { try { await sendo.send.template(phone, "recordatorio_cita", { tenant: tenantId, language: "es" }); return null; } catch (error) { if (error instanceof MetaApiError && error.hint === "billing_not_ready") { // Meta error 131042: the number is now billingReady = false and account.billing fired. return "Este número aún no tiene método de pago en Meta"; } throw error; } } // "I added it, check again" on the numbers screen. export async function verifyBilling(accountId: string) { const account = await sendo.accounts.refreshBilling(accountId); return account.billingReady; // true, false, or null (Meta didn't say) } ``` --- # Coexistence URL: https://sendo.quami.dev/docs/coexistence > Keep using the WhatsApp Business app on a number the inbox also works: Meta's seven limits, echoes, agent pause and history import. Coexistence lets a business keep the WhatsApp Business app on its phone while the same number works through the Cloud API, so the owner answers from the phone and the team (and the AI) answers from your inbox. Sendo stores what the owner sends from the phone, pauses the AI where the owner stepped in and, if you want, imports the phone’s chat history. > **Warning: Read the limits before you promise coexistence** > > Coexistence has changed several times at Meta. The [seven limits](#limits) below come from providers’ documentation and are each marked **pending confirmation against Meta’s own docs** until they are checked during the real-Meta smoke run; the mark comes off per item as each one is confirmed. Every page of this site that promotes coexistence links here. ## Meta’s requirements - The number is onboarded through **Embedded Signup** with the WhatsApp Business app flavour (`featureType: "whatsapp_business_app_onboarding"`, `sessionInfoVersion: "3"` or later). The older `coexistence` value is deprecated, and Embedded Signup v2 is retired around October 15, 2026. - You are a Meta **Solution Partner or Tech Provider**, with Embedded Signup session logging on (see [Embedded Signup](https://sendo.quami.dev/docs/embedded-signup)). - The business uses a recent WhatsApp Business app (the minimum version is the [first limit](#limits)). - Your app subscribes to three extra **webhook fields**: `smb_message_echoes`, `history` and `smb_app_state_sync` (next to `messages`). - Sendo doesn’t call `register` for a coexistence number (the phone app keeps it registered), and the account is stored with `coexistence = true`. ## The seven limits Each limit is marked _pending confirmation against Meta’s own docs_ until it is verified. 1. **WhatsApp Business app 2.24.17 or newer.** _Pending confirmation against Meta’s own docs._ 2. **The phone app must be opened at least every 13 days or the account is deactivated.** This is the one that bites: if the admin forgets during a vacation, the number goes down and your app takes the blame. Put a reminder on someone’s calendar. _Pending confirmation against Meta’s own docs._ 3. **Up to 6 months of history syncs.** Attachments older than 14 days before onboarding may arrive without a usable file. _Pending confirmation against Meta’s own docs._ 4. **Groups don’t sync** and aren’t supported through the Cloud API in this flow. _Pending confirmation against Meta’s own docs._ 5. **Disappearing, view-once and live-location messages are disabled** in individual chats after onboarding. _Pending confirmation against Meta’s own docs._ 6. **No verified badge** with coexistence. _Pending confirmation against Meta’s own docs._ 7. **20 messages per second** combined between the app and the API, a real limit for [broadcasts](https://sendo.quami.dev/docs/broadcasts). _Pending confirmation against Meta’s own docs._ ### Other things to know - **24-hour window:** only messages received after onboarding open it. Messages sent from the phone app don’t open or extend the window. - **History sync:** contacts and history can each be synced once, within 24 hours of onboarding. Media in history arrives after the messages. - **Not available through the Cloud API either:** broadcast lists (existing ones become read-only), voice and video calls, catalogs/orders/status, quick replies, labels, greeting and away messages, business profile settings and Channels. - **Companion devices:** up to four; onboarding unlinks the existing ones (WhatsApp for Windows and Wear OS aren’t supported). - The number can’t be deregistered through the API while it is shared; the business disconnects it from the app. ## What Sendo does | Webhook field | Sendo | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `smb_message_echoes` | Each message the owner sent from the phone is stored as an outbound message: sender `human`, `senderRef: "business_app"`, `metadata.origin: "business_app"`, status `sent`, its Meta id. The inbox shows it as "Desde el teléfono". A repeated echo is stored once. | | `history` | With `importHistory`, each message is stored with `metadata.imported = true` (the owner’s with `senderRef: "business_app"`). Imported messages never count as unread, never open the window and never start an AI turn. Without it, the webhook is acknowledged and ignored. | | `smb_app_state_sync` | Acknowledged (contact sync isn’t stored yet). Subscribe anyway: Meta requires it for coexistence. | An echo updates the conversation’s last activity and clears “awaiting reply”, but never `last_inbound_at` or the window. It creates the contact and conversation if the owner started the chat from the phone. It doesn’t fire the `message` event (that one is for inbound messages). ### The AI steps aside When the owner answers from the phone in a conversation the AI is handling (status `agent`), Sendo pauses the AI **on that conversation** so the owner and the AI never double-answer. It emits `conversation.agent_paused` with `by: "business_app"`. Later inbound messages are stored, count as unread and keep the window open, but the AI runs no turns until the conversation is resumed from the inbox. Turn it off with `pauseAgentOnPhoneReply: false`. A reply the AI was already writing when the echo arrived may still go out. This is a known limit: the pause stops the next turn, not one in flight. ```ts title="ts" sendo.on("conversation.agent_paused", ({ conversation, paused, by }) => { if (by === "business_app" && paused) { console.log(`The owner answered ${conversation.id} from the phone; the AI is paused there.`); } }); ``` ## Config | Option | Default | Notes | | ------------------------------------ | ------- | ------------------------------------------------------------------------- | | `coexistence.pauseAgentOnPhoneReply` | `true` | Pause the AI on a conversation when the owner replies from the phone app. | | `coexistence.importHistory` | `false` | Store `history` webhooks as imported messages. | ```ts title="ts" export const sendo = createSendo({ adapter: memoryAdapter(), appSecret: process.env.SENDO_META_APP_SECRET ?? "", verifyToken: process.env.SENDO_VERIFY_TOKEN ?? "", resolveToken: (account) => process.env[account.tokenRef], coexistence: { // An owner's reply from the phone pauses the AI on that conversation (default true). pauseAgentOnPhoneReply: true, // Store the phone app's chat history when Meta sends it (default false). importHistory: true, }, }); ``` ## Known gaps - Imported messages keep Meta’s timestamp in `sent_at` (incoming) but are ordered in the thread by when Sendo stored them. - Media inside echoes is downloaded like inbound media; media inside history is not (Meta sends it separately and only for 14 days). - Edits and revokes made from the phone aren’t applied to stored messages yet. --- # Links, QR and chat bubble URL: https://sendo.quami.dev/docs/widgets > Bring conversations in with wa.me links, printable QR codes and a chat bubble, and see how many came from each source. A conversation that starts from a sign at the gate, a link on an invoice or a button on your website should say so. Sendo builds a WhatsApp click-to-chat link whose prefilled message ends with a short visible code, like `[#garita]`. When a contact sends that message, the new conversation is tagged with the source, and you can count conversations per source. ## Create a link `sendo.links.create({ account?, tenant?, text?, source })` returns `{ url, code, phone, text }`. It picks the number the same way `send.*` does (an explicit `account`, else the tenant’s default number), reads it, and writes nothing. ```ts title="ts" // The tenant's default number (or pass `account`: its id or Meta phone number id). const link = await sendo.links.create({ tenant: tenantId, text: "Hola, quiero información", source: "garita", }); // link.url → "https://wa.me/50255551234?text=Hola%2C%20quiero%20informaci%C3%B3n%20%5B%23garita%5D" // link.text → "Hola, quiero información [#garita]" // link.code → "[#garita]" // link.phone → "+50255551234" ``` - `source` must match `^[a-z0-9-]{1,32}$` (lowercase letters, digits and `-`). Anything else throws `INVALID_PAYLOAD`. - The URL is `https://wa.me/?text=`, percent-encoded. Without `text` the message is just the code. - The contact can edit the message before sending it. If they delete the code, the conversation is simply “direct”. When you already know the number, `buildWhatsAppLink` builds the same link with no lookup: ```ts title="ts" // No lookup at all when you already know the number. const direct = buildWhatsAppLink({ phone: "+502 5555 1234", text: "Hola", source: "factura" }); ``` ## How attribution works - The pipeline reads a trailing `[#code]` from the **first inbound message of a new or reopened conversation** only. It sets `conversation.source` and `message.metadata.source`. A code in a later message is ignored. - The stored message keeps the full text. The AI agent gets the text without the code, and the inbox shows it without the code plus an “Origin: garita” chip (“Origen: garita” in Spanish). - A Click-to-WhatsApp ad sets `source = "ad:"` from Meta’s `referral`. With neither, `source` is `null` (direct). ## Count conversations per source ```ts title="ts" // Admin only (`numbers.manage`); scoped to the actor's tenant. `source: null` = direct. const sources = await sendo.links.sources({ actor: admin, from: new Date("2026-10-01") }); // [{ source: "garita", conversations: 42 }, { source: null, conversations: 17 }, ...] ``` Sorted by count, most first, ties by name, `null` (direct) last among equals. It needs an actor with `numbers.manage` (admins, see the roles table in the inbox docs) and throws `FORBIDDEN` otherwise. Pass `account` to count a single number, and `from` / `to` to limit by when the conversation was created. ## The `widgets` registry item ```shell npx shadcn add @sendo/widgets ``` It adds: - **The links screen** at `/inbox/links` (`app/(protected)/inbox/links`): pick a number, write the message and the source, then copy the link, download the QR as PNG or SVG, and copy the chat bubble snippet. Below it, the sources table with conversation counts. Admins only. - **``** (`components/sendo/widgets/qr-code.tsx`): error correction H (Q when the message makes the code too dense, never lower), a 4-module quiet zone, always black on white so phones read it in dark mode too. The PNG and SVG are generated in the browser; no external QR service sees your links. It uses `qrcode-generator`, a dependency of the item, not of `@quami/sendo`. - **``** (`components/sendo/widgets/chat-bubble.tsx`) for your public site. Before using the screen, implement `requireUser()` in `app/(protected)/inbox/links/links-config.ts` (it throws until you do) and configure `resolveRole` in `createSendo`. The screen renders in English or Spanish: the page passes `locale={sendoLocale}` from `lib/sendo-ui.ts`, and counts and shares follow the optional `formatLocale`. Override single texts with the `strings` prop or edit `components/sendo/widgets/strings.ts`; see [Languages](https://sendo.quami.dev/docs/languages). ## Chat bubble A floating button that opens WhatsApp with the message ready. It’s a plain link, so it works in Server and Client Components and needs no client JavaScript. It has hover and press states, and with `prefers-reduced-motion` the press doesn’t scale. ```tsx title="app/layout.tsx" import { SendoChatBubble } from "@/components/sendo/widgets/chat-bubble"; export default function Layout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` | Prop | Type | Notes | | ---------- | --------------------------------- | -------------------------------------------------------------------------------- | | `locale` | `"en" \| "es"` | Your public site's language: the accessible name and tooltip. Required. | | `phone` | `string` | Any format; only the digits are used. Renders nothing when it isn’t 7–15 digits. | | `text` | `string?` | Prefilled message. | | `source` | `string?` | Appended as `[#source]`. | | `href` | `string?` | A link from `sendo.links.create` (overrides `phone`, `text`, `source`). | | `position` | `"bottom-right" \| "bottom-left"` | Default `bottom-right`. | | `label` | `string?` | Accessible name and tooltip. Default “Message us on WhatsApp” (`en`). | To pick the number by account or tenant instead of by phone, build the link on the server and pass `href={link.url}`. ## Without React The bubble is a convenience. Any site, email or PDF can use the plain link, and attribution works the same: ```html Escríbenos por WhatsApp ``` Encode the whole message with `encodeURIComponent` (`[` is `%5B`, `#` is `%23`, `]` is `%5D`). There is no non-React embed script for the bubble. ## Why not Instagram or Messenger links yet Instagram (`ig.me`) and Messenger (`m.me`) have their own click-to-chat links, and Sendo’s schema already has a `channel` column for them. But only WhatsApp messages are parsed and answered today. A link to a channel whose messages never reach your inbox would be a broken promise, so the link builder is WhatsApp-only and will add `ig.me` / `m.me` when those channels ship. --- # CLI URL: https://sendo.quami.dev/docs/cli > npx @quami/sendo doctor, accounts add, list, set-default, disconnect and export, and rules sync: checks, flags and environment. The package ships a `sendo` binary. Run it from your app’s root with `npx @quami/sendo ` (or `npx sendo` once installed). Output, prompts and errors are in English; `--help` lists every flag. ```shell title="usage" sendo doctor [--account ] [--url ] [--json] [--config ] sendo accounts add [--phone-number-id ] [--waba-id ] [--token-env ] [--tenant-id ] [--display-phone ] [--timezone ] [--yes] sendo accounts list [--tenant-id ] [--json] sendo accounts set-default sendo accounts disconnect sendo accounts export [--out ] sendo rules sync --config [--force] [--tenant ] sendo mcp [--config ] sendo --help | --version ``` ## Environment Both commands load `.env.development.local`, `.env.local` and `.env` from the current directory, in that order, without overriding variables already set in the shell. To check production values, export them or run the command where they are defined. | Variable | Used for | | ------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `SENDO_META_APP_SECRET` | Required (env check), unless the Connect variables are set. | | `SENDO_VERIFY_TOKEN` | Required; also used for the --url challenge. | | `SUPABASE_URL or NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL. | | `SUPABASE_SERVICE_ROLE_KEY` | Reads sendo_accounts, schema and storage; accounts add inserts with it. | | `SENDO_STORAGE_BUCKET` | Bucket to check. Default sendo-media. | | `` | One per account, e.g. SENDO_TOKEN_MAIN. Must be set locally for doctor and accounts add. | | `SENDO_META_APP_ID` | Optional. When set, doctor also checks this app is in the WABA's subscribed apps. | | `SENDO_CONNECT_API_KEY`, `SENDO_CONNECT_SIGNING_SECRET` | Sendo Connect: when either is set both are required and the `connect:*` checks run. | | `SENDO_CONNECT_BASE_URL` | Optional. Default https://connect.sendo.quami.dev. | > **Note:** > > Values of `SENDO_META_APP_SECRET`, `SENDO_VERIFY_TOKEN`, `SUPABASE_SERVICE_ROLE_KEY` and every resolved token, plus any env var whose name contains TOKEN, SECRET, KEY or PASSWORD, are scrubbed from the output. ## doctor ```shell npx @quami/sendo doctor npx @quami/sendo doctor --account 123456789012345 # one account (id or phone_number_id) npx @quami/sendo doctor --url https:///api/sendo/webhook # also test the webhook challenge npx @quami/sendo doctor --json # machine-readable results ``` Prints a pass/fail table with a fix hint under each failure. Checks never throw: a broken step is reported and the rest still run. Each failure line is `FAIL`, followed by `-> Fix:` and a docs link. Warnings (`WARN`, `warning: true` in `--json`) carry a fix too but don't change the exit code. ## doctor checks | id | Passes when | Typical fix | | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `env` | SENDO_META_APP_SECRET, SENDO_VERIFY_TOKEN, Supabase URL + service role key and every account's token_ref are set. | Add them to .env.local / the deployment. | | `tables` | All sendo\_\* tables exist. | Apply the schema migration. | | `schema-version` | sendo_schema_meta matches the package (SENDO_SCHEMA_VERSION). | Apply the latest migration. | | `storage` | The SENDO_STORAGE_BUCKET bucket exists. | Create a private bucket. | | `accounts` | At least one active sendo_accounts row. | npx @quami/sendo accounts add | | `account::token` | Reported (failing) only when the token env var is empty. | Set the variable named by token_ref. | | `account::phone` | `GET /{phone_number_id}` works with the token; shows verification status and quality rating. RED quality fails. | Token expired (190) or wrong phone_number_id. | | `account::subscription` | `GET /{waba_id}/subscribed_apps` lists at least one app (and SENDO_META_APP_ID, if set). | `POST /{waba_id}/subscribed_apps`; subscribe the messages field. | | `account::templates` | Templates can be listed from Meta (counts approved ones). | Token needs whatsapp_business_management. | | `account::billing` | The stored `billingReady` is `true`. `false` or unknown (`null`) is a **warning**, not a failure. | Add a payment method in Meta Business Settings, then `sendo.accounts.refreshBilling(id)` ([Billing state](https://sendo.quami.dev/docs/embedded-signup#billing)). | | `webhook-url` | Only with --url: the GET verify challenge echoes the challenge with SENDO_VERIFY_TOKEN. | Deploy the route, align SENDO_VERIFY_TOKEN. | | `connect:key`, `connect:webhook`, `connect:delivery` | Only with the Connect env vars: Connect accepts the key, has your webhook URL registered (equal to --url, if given) and its last delivery got a 2xx (none yet is a warning). | See [Sendo Connect](https://sendo.quami.dev/docs/sendo-connect#doctor). | The template check lists templates from Meta; it doesn’t write `sendo_templates`. Your app populates that table with `sendo.templates.sync()`. ## accounts add ```shell # interactive (prompts for anything missing) npx @quami/sendo accounts add # non-interactive npx @quami/sendo accounts add --phone-number-id 123456789012345 --token-env SENDO_TOKEN_MAIN \ --waba-id 987654321098765 --tenant-id --display-phone "+502 5555 1234" \ --timezone America/Guatemala --yes ``` | Flag | Notes | | ------------------- | --------------------------------------------------------------------------------------------------------------------- | | `--phone-number-id` | Required. Meta Phone number ID. | | `--token-env` | Required. Name of the env var with the token; it must be set and non-empty, or the command stops before calling Meta. | | `--waba-id` | Optional. Looked up from the token's debug_token scopes when there's a single candidate; otherwise asked for. | | `--display-phone` | Optional. Fetched from Meta by default. | | `--tenant-id` | Optional uuid. Default: no tenant (null). | | `--timezone` | Optional IANA zone. Default America/Guatemala. | | `--yes, -y` | No prompts; fail if a required value is missing. Prompts only happen on a TTY. | It inserts the row through the Supabase adapter and stores only the env var name in `token_ref`. An existing `phone_number_id` fails with `DuplicateAccountError`. To change a row later, use the commands below or [`sendo.accounts`](https://sendo.quami.dev/docs/numbers-and-tenants#accounts). ## accounts list, set-default, disconnect ```shell npx @quami/sendo accounts list # every number, connected or not npx @quami/sendo accounts list --tenant-id --json npx @quami/sendo accounts set-default 123456789012345 # id or phone_number_id npx @quami/sendo accounts disconnect 123456789012345 ``` - `list` prints id, number, label, tenant, status (connected / disconnected), whether it is the tenant’s default and how it was onboarded. `--json` prints the `Account` objects. - `set-default` makes the number its tenant’s default (atomic swap). A disconnected number exits 1. - `disconnect` does what [`sendo.accounts.disconnect`](https://sendo.quami.dev/docs/numbers-and-tenants#disconnect) does, except that the CLI has no `deleteToken` hook: it reminds you to delete the token yourself. For Embedded Signup numbers it unsubscribes the app from the WABA with the token in the env var named by `token_ref`, when it is set. ## accounts export ```shell npx @quami/sendo accounts export 123456789012345 --out history.json # id or phone_number_id npx @quami/sendo accounts export 123456789012345 > history.json ``` Writes the number's whole history, connected or disconnected, as one JSON document: the same [v1 export](https://sendo.quami.dev/docs/retention#export-format) as `sendo.accounts.export` (contacts, tags, conversations with every message, internal note, assignment and system event, and media references with URLs signed for 7 days). Without `--out` the document goes to stdout; progress and the summary go to stderr, so redirecting stdout gives a clean file. With `--out` it writes `.partial` and renames it when done, so a failed export leaves no file. The export streams page by page and is a snapshot as of when it started. Like the other `accounts` commands it uses the Supabase adapter from env and trusts the operator (no actor). Deleting the history is a separate step: [purge](https://sendo.quami.dev/docs/retention#purge). ## rules sync Stores the rules you declare in code in `sendo_rules`, where the rules screen and the engine read them. ```shell npx @quami/sendo rules sync --config lib/sendo-rules.ts --tenant npx @quami/sendo rules sync --config lib/sendo-rules.ts --tenant --force ``` - `--config` loads a JS or TS module (through `jiti`, CLI only) that exports `rules: RuleDefinition[]`. Keep the rules in a standalone file such as `lib/sendo-rules.ts` and import it from `lib/sendo.ts` too, so the CLI never loads Next.js-only code. - Every rule is validated with the package's zod schemas first; one invalid rule exits 1 and nothing is written. - Rules are inserted by `(tenant, key)` with origin `code`. A rule that already exists (for example, edited on the rules screen) is left as is unless you pass `--force`, which replaces its type, params and account; its position and on/off state stay as the screen left them. - `--tenant` is the tenant of rules that don't declare `tenant`; rules declaring another tenant are skipped. A rule's `account` may be the sendo account id or the phone number id. ## mcp `sendo mcp` runs a stdio MCP server for coding agents (docs search, API reference, registry items with their `AGENTS.md`, read-only doctor). You don't run it by hand: register `npx -y @quami/sendo mcp` in Claude Code or Cursor, see [Para agentes](https://sendo.quami.dev/docs/agents-docs#setup). `--config ` (doctor and mcp) loads a JS or TS module that exports `adapter`, for apps whose storage isn't the Supabase adapter built from env (see [run_doctor and your adapter](https://sendo.quami.dev/docs/agents-docs#doctor)). ## Exit codes | Code | Meaning | | ---- | ------------------------------------------------------------------------ | | 0 | Every check passed / the accounts or rules command worked. | | 1 | At least one check failed (warnings don't count), or the command failed. | | 2 | Usage error (unknown command or flag). | ## Programmatic use `runDoctor`, `doctorExitCode`, `addAccount` and `findWabaIdsForToken` are exported from `@quami/sendo`: ```ts title="ts" import { createGraphClient, doctorExitCode, runDoctor } from "@quami/sendo"; import { memoryAdapter } from "@quami/sendo/adapters/memory"; // The CLI is a thin wrapper: run the same checks from a test or a health endpoint. const results = await runDoctor({ env: process.env, adapter: memoryAdapter(), // or supabaseAdapter(...); null when it can't be built graph: createGraphClient(), url: "https://example.com/api/sendo/webhook", }); for (const { id, ok, detail, hint } of results) { console.log(ok ? "✓" : "✗", id, detail, hint ?? ""); } process.exitCode = doctorExitCode(results); // 0 or 1 ``` - Each result is `{ id, label, ok, detail, fix?, hint?, warning? }`; `detail`, `fix` and `hint` never contain secrets. `warning: true` marks a failing check that doesn't fail the run. - Setup walkthrough: [Quickstart step 8](https://sendo.quami.dev/docs/quickstart#doctor). --- # Metrics URL: https://sendo.quami.dev/docs/metrics > Messages per day, first response time, work per person, AI resolution, sources, tags and cost: the sendo.metrics API, the Metrics screen and the SQL behind them. Sendo measures the inbox from the rows it already stores; there is nothing to collect or schedule. Five SQL views (schema v3) define every number, `sendo.metrics.*` reads them with role checks, and the `metrics` registry item draws the screen supervisors and admins use. ## The API Every method takes `{ actor, from, to, account?, tagIds? }`: - `actor` comes from `sendo.actor(userId, tenantId)`. The tenant is always the actor's. - `from` / `to` are a half-open range of instants. Build them from calendar days with `metricsDayRange({ from, to, timeZone })`: both days are included and the bounds are local midnights in that timezone. - `account` narrows to one number (account id or Meta phone number id of the tenant; disconnected numbers keep their history). Another tenant's number is `ACCOUNT_NOT_FOUND`. - `tagIds` is any-match on the conversation's current tags (the tag cut). ```ts title="ts" // Supervisors and admins (`metrics.view`); agents get FORBIDDEN. const actor: Actor = await sendo.actor(userId, tenantId); // October, both days included, in the number's timezone (local midnights, DST-safe). const range = metricsDayRange({ from: "2026-10-01", to: "2026-10-31", timeZone: "America/Guatemala", }); const daily = await sendo.metrics.daily({ actor, ...range }); // per day and number const people = await sendo.metrics.byAssignee({ actor, ...range }); // names from resolveUsers const firstResponse = await sendo.metrics.firstResponse({ actor, ...range, account: phoneNumberId, }); const resolved = await sendo.metrics.aiResolution({ actor, ...range, tagIds: [ventasTagId] }); const sources = await sendo.metrics.bySource({ actor, ...range }); // Admins only (`costs.view`): supervisors get FORBIDDEN here and `costCents: null` in `daily`. const cost = await sendo.metrics.cost({ actor, ...range }); ``` | Method | Returns | Needs | | --------------- | --------------------------------------------------------------------------------------------------------------- | -------------- | | `daily` | Per day and number: `inbound`, `outbound`, `conversationsStarted`, `conversationsClosed`, `costCents`, tokens | `metrics.view` | | `byAssignee` | Per person and day: `conversations`, `messagesSent`, `firstResponses`, `firstResponseSeconds[]`, median, `name` | `metrics.view` | | `firstResponse` | Per conversation whose first inbound message is in range: who answered first and after how many `seconds` | `metrics.view` | | `aiResolution` | Per conversation closed in range: `everHuman`, `humanMessages`, `agentMessages`, `aiResolved` | `metrics.view` | | `bySource` | New conversations per source and day (`source: null` = direct) | `metrics.view` | | `cost` | Model cost per AI agent and day: `costCents` (US cents), `tokensIn`, `tokensOut`, `turns` | `costs.view` | Supervisors and admins have `metrics.view`; agents get `FORBIDDEN`. Only admins have `costs.view`: for everyone else `cost` is `FORBIDDEN` and `daily` returns `costCents`, `tokensIn` and `tokensOut` as `null`. `byAssignee` adds `name` and `avatarUrl` from your `resolveUsers` (`null` when it isn't configured or doesn't know the id; a failing `resolveUsers` is logged and the names stay `null`). Phone replies from the WhatsApp Business app appear as `business_app`. Medians are exact across days: merge the `firstResponseSeconds` arrays of the days you want and take the middle value (the mean of the two middle values for an even count), as the screen does. ## Definitions - **Days** are calendar days in each number's timezone (`sendo_accounts.timezone`). A daily row counts when its local midnight is inside `[from, to)`. With numbers in several timezones, read one number at a time for exact days. - **Messages** exclude internal notes, system lines, drafts and imported history. Inbound counts everything the contact sent; outbound counts what the agent, a person or the phone sent to the contact. - **First response** is the first agent or human reply (not a note, not a draft) after the conversation's first inbound message. - **Resolved by the AI** is a closed conversation that was never handed to a person (`ever_human = false`) and has no human message. Its close time is the last close system line, else the last activity. - **Source** is `conversation.source` (`[#code]` from a link, `ad:` from a Meta ad), counted when the conversation starts. - **Cost** is the stored `cost_cents` of the agent's messages, drafts included (they cost the same to generate). ## The Metrics screen ```bash npx shadcn add @sendo/metrics ``` The item adds `app/(protected)/inbox/metrics` with a date range (7, 30, 90 days or custom), number and tag filters, four KPI tiles (messages, new conversations, median first response, % resolved by the AI), a daily volume chart with a table view, tables per person, per source and per tag, and, for admins, cost per day and per agent. Implement `requireUser()` in `app/(protected)/inbox/metrics/metrics-config.ts` (it throws until you do) and configure `resolveRole`; agents see "You don't have access to Metrics". The screen renders in English or Spanish: the page passes `locale={sendoLocale}` from `lib/sendo-ui.ts`, and numbers, costs and days follow the optional `formatLocale`. Override single texts with the `strings` prop or edit `components/sendo/metrics/strings.ts`; see [Languages](https://sendo.quami.dev/docs/languages). The chart is plain HTML and CSS (no chart library), with hover and keyboard tooltips and a table view; see `components/sendo/metrics/AGENTS.md` for the colours and how to change them. ## SQL for Drizzle and Prisma Supabase apps get the five views from the `schema` migration v3 (`sendo_metrics_daily`, `sendo_metrics_by_assignee`, `sendo_metrics_first_response`, `sendo_metrics_ai_resolution`, `sendo_metrics_by_source`, all `security_invoker = true` so RLS applies). For any other Postgres client, `@quami/sendo` exports the same queries over the base tables as parameterized strings, so the numbers match the views exactly: - `METRICS_SQL.daily`, `.byAssignee`, `.firstResponse`, `.aiResolution`, `.bySource`, `.cost` - `metricsSqlParams({ tenantId, from, to, accountId?, tagIds? })` → `[$1 tenant, $2 from, $3 to, $4 account | null, $5 tag ids | null]` - `METRICS_VIEWS`: the select body of each view, if you'd rather create the views yourself ```ts title="node-postgres or Drizzle" // Any Postgres client that takes `$1…$5` parameters. Every query returns view rows (snake_case, // one row per day × number × tag set): sum them per day yourself, or use `sendo.metrics.*`. interface PgClient { query(text: string, params: unknown[]): Promise<{ rows: Record[] }>; } declare const pool: PgClient; // node-postgres Pool, or Drizzle's `db.$client` const params = metricsSqlParams({ tenantId, ...range, tagIds: [ventasTagId] }); const { rows: dailyRows } = await pool.query(METRICS_SQL.daily, params); ``` ```ts title="Prisma" interface PrismaLike { $queryRawUnsafe(query: string, ...values: unknown[]): Promise; } declare const prisma: PrismaLike; const sourceRows = await prisma.$queryRawUnsafe[]>( METRICS_SQL.bySource, ...metricsSqlParams({ tenantId, ...range }), ); ``` ```ts title="ts" // The select bodies of the five `sendo_metrics_*` views (as the v3 migration creates them). const ddl = Object.entries(METRICS_VIEWS) .map( ([name, body]) => `create or replace view public.${name}\nwith (security_invoker = true) as\n${body};`, ) .join("\n\n"); ``` The queries return view rows in snake_case, one per day, number and tag set (so tag cuts stay exact). Sum them per day, or let the Drizzle and Prisma adapters do it: they run these queries behind `sendo.metrics.*`, so the API and the screen work the same on every adapter. > **Note:** > > The SQL takes the tenant as a parameter and does no role check. Call it only after your own check, or use `sendo.metrics.*`, which checks `metrics.view` and `costs.view` for you. --- # Voice notes and cost URL: https://sendo.quami.dev/docs/voice-notes-and-cost > Transcribe inbound voice notes before the agent answers, and see what every agent turn and conversation cost. Contacts send voice notes all the time. With one config option Sendo transcribes them before the agent turn, so the agent answers what was said and the inbox shows the transcript under the audio. Every agent turn also records its tokens and cost, which admins see in the inbox and in [Metrics](https://sendo.quami.dev/docs/metrics). ## Voice notes Pass any [AI SDK transcription model](https://ai-sdk.dev/docs/ai-sdk-core/transcription) as `transcribe.model`: ```ts title="lib/sendo.ts" const sendo = createSendo({ adapter: memoryAdapter(), appSecret: process.env.SENDO_META_APP_SECRET ?? "", verifyToken: process.env.SENDO_VERIFY_TOKEN ?? "", resolveToken: (account) => process.env[account.tokenRef], // Inbound voice notes are transcribed after the download and before the agent turn. transcribe: { model: whisper }, }); ``` For each inbound `audio` message, the webhook pipeline: 1. downloads the audio into your media store (as for any media); 2. sends the bytes to the model with `experimental_transcribe` from `ai`, waiting at most 30 seconds; 3. stores the transcript as the message `text`, with `metadata.transcription = { model, ms }`; 4. then runs the opt-out check, the rules, the `message` event and the agent turn, which all see the transcript. The agent reads a transcribed note as `[voice note] `. Rules that match keywords match the transcript too. `transcribe.timeoutMs` lowers the 30-second limit (1–30000). Without `transcribe`, nothing is sent to a model and the agent reads untranscribed voice notes as “[untranscribed voice note]”. ### When there is no transcript Transcription never fails the webhook or the agent turn. When it doesn't produce text, `text` stays `null`, the agent reads “[untranscribed voice note]” and `metadata.transcription.error` says why: | `error` | What happened | | ----------- | --------------------------------------------------------------------------- | | `too_large` | The note is over 25 MB (the usual provider limit); the model is not called. | | `timeout` | The model didn't answer within the time limit; the call is aborted. | | `no_audio` | The audio couldn't be downloaded or read from the media store. | | `empty` | The model returned no text. | | `failed` | The model threw (provider error, bad key…); the error is logged. | In the inbox the operator sees the audio player with the transcript below it, or a “Sin transcripción” label whose tooltip gives the reason. `sendo.inbox.thread` exposes the same record as `transcription: { model, ms, error } | null` on each message (`null` when no transcription was attempted). > **Note:** > > Transcription bills your speech-to-text provider per audio minute. It runs for every inbound voice note, including those of conversations handled by a person, so the team can read them too. ## Cost per agent turn Each agent reply or draft stores `tokensIn`, `tokensOut` and `costCents` (US cents). The cost comes from the provider’s reported cost when available (for example OpenRouter), otherwise from the agent’s `pricing` (`{ inputPerMTok, outputPerMTok }` in USD), otherwise it is `null`. Details in [Agents and models](https://sendo.quami.dev/docs/agents-and-models#cost). Only admins (`costs.view`) see cost and tokens; for everyone else `sendo.inbox.thread` returns them as `null`. ## Cost in the inbox - **Per turn:** hovering an agent reply shows its cost and tokens (`$0.0024 · 1102 → 64 tokens`), or “—” when the cost is unknown. On touch screens the line is always visible. Other roles never see it. - **Per conversation:** the context panel’s “Costo del agente” shows the number of agent turns and their total cost for the whole conversation, plus how many turns have no known cost. The total comes from `sendo.inbox.cost`, which the inbox `thread` action calls for admins: ```ts title="ts" // Admins only (`costs.view`); everyone else gets FORBIDDEN. const actor: Actor = await sendo.actor(userId, tenantId); const cost = await sendo.inbox.cost(conversationId, { actor }); // { turns: 12, tokensIn: 13_480, tokensOut: 702, costCents: 2.71, unpricedTurns: 0 } console.log(cost); ``` `turns` counts agent replies and pending drafts (the model ran for both). `costCents` is `null` when no turn has a known cost. Cost per day and per agent across conversations is in [Metrics](https://sendo.quami.dev/docs/metrics). --- # Broadcasts and opt-in URL: https://sendo.quami.dev/docs/broadcasts > Send one approved template to many contacts: who is eligible by category, opt-in and opt-out keywords, the tick, billing pauses and the Broadcasts screen. A broadcast sends one APPROVED WhatsApp template to a list of contacts, a batch per minute, and keeps a result per recipient. Sendo decides who may receive it from the template's category and the contact's opt-in, and you can't override that: the preview tells you how many will get it and why the others are skipped, before anything is sent. ## Who receives a broadcast | Template category | Who receives it | Skipped as | | ------------------------------- | ------------------------------------------------------------- | ----------- | | `MARKETING` | Only contacts with `marketingOptIn = true` (an explicit yes). | `no_opt_in` | | `UTILITY`, `AUTHENTICATION` | Every contact who hasn't explicitly opted out. | `opted_out` | | Any category Sendo doesn't know | Treated as `MARKETING`. | `no_opt_in` | - **Opted out** means `marketingOptIn = false` **with** a decision date (`optInAt`): the contact said no, by keyword or through your app. A contact nobody asked yet has `optInAt = null`: they get utility messages ("tu cobro de noviembre fue emitido") but not promotions. - Phones that don't parse are skipped as `invalid_phone` and never stored as contacts. - Only APPROVED, active templates can be broadcast (`TEMPLATE_NOT_FOUND` otherwise). Sync them first with `sendo.templates.sync()`. - Eligibility is checked again when each message is sent, so a contact who replies BAJA in the middle of a broadcast doesn't get it. > **Note:** > > Pick the category honestly when you create the template in Meta. Meta re-categorizes templates whose content doesn't match, and marketing sent as utility can get the template paused. ## Opt-in and opt-out Record a contact's explicit decision with `sendo.contacts.setOptIn(contactId, optIn, { source })`. It sets `marketingOptIn` and `optInAt`, and emits `contact.opt_in` (repeating the stored decision is a no-op). ```ts title="ts" // The contact said yes in your signup form: MARKETING broadcasts may reach them now. await sendo.contacts.setOptIn(patientContactId, true, { source: "form" }); sendo.on("contact.opt_in", ({ contact, optIn, source, keyword }) => { // source "keyword" + keyword "baja" when they replied BAJA; mirror it in your own records. console.log(contact.id, optIn, source, keyword); }); ``` **Opt-out keywords.** An inbound message that is exactly one of the keywords (default `BAJA`, `STOP`, `ALTO`, `CANCELAR`), after trimming and ignoring case and accents (`" Cancelár "` matches), opts the contact out: `marketingOptIn = false`, `contact.opt_in` with `source: "keyword"`, a confirmation message inside the 24-hour window (`optOut.confirmation`, default `BUILT_IN_MESSAGES[locale].optOutConfirmation`, so English unless `createSendo({ locale: "es" })`), and no agent turn for that message. "Quiero darme de baja" doesn't match; handle phrases in your agent or a rule if you want them. ```ts title="ts" const sendo = createSendo({ adapter: memoryAdapter(), appSecret: process.env.SENDO_META_APP_SECRET ?? "", verifyToken: process.env.SENDO_VERIFY_TOKEN ?? "", resolveToken: (account) => process.env[account.tokenRef], // Optional: these are the defaults. `keywords: []` turns keyword opt-out off. optOut: { keywords: ["BAJA", "STOP", "ALTO", "CANCELAR"], confirmation: "Listo, ya no te enviaremos mensajes promocionales. Si cambias de opinión, escríbenos.", }, }); ``` Add the opt-out sentence to your marketing templates ("Responde BAJA si no quieres recibir promociones") so contacts know how. ## Preview, create and follow Every `sendo.broadcasts` method takes an admin actor (`broadcasts.manage`). Recipients are contact ids, `{ phone }` (upserted as a contact on create) or either with its own `variables`, which win over the shared ones. ```ts title="ts" const input = { actor: admin, template: "recordatorio_cita", language: "es", recipients: [ { phone: "+502 5555 0101", variables: ["Ana", "lunes 12"] }, { contactId: patientContactId, variables: ["Luis", "martes 13"] }, ], ratePerMinute: 60, }; const preview = await sendo.broadcasts.preview(input); // { total: 2, eligible: 2, skipped: { no_opt_in: 0, opted_out: 0, invalid_phone: 0 }, // category: "UTILITY", ratePerMinute: 60, estimatedMinutes: 1, accountId: "…" } ``` ```ts title="ts" const broadcast = await sendo.broadcasts.create(input); // status "queued"; the tick sends it const progress = await sendo.broadcasts.get(broadcast.id, { actor: admin }); // progress.counts → { pending, sent, delivered, read, failed, skipped, cancelled } const failed = await sendo.broadcasts.recipients(broadcast.id, { actor: admin, status: "failed" }); // failed.items[0].error → { code: 131026, title: "Message undeliverable", message: "…" } await sendo.broadcasts.cancel(broadcast.id, { actor: admin }); // pending → cancelled ``` - `create` stores the broadcast as `queued` and each recipient as `pending` or `skipped` with the reason. Nothing is sent yet. - **The tick sends it.** `sendo.maintenance.tick()` (every minute, see [the tick](https://sendo.quami.dev/docs/workflows#tick)) sends up to `ratePerMinute` pending recipients per broadcast (default 60), using the same conversation as any other message. A broadcast never moves a conversation to `human` and never runs the agent for the template; replies land in the normal conversation and the inbox. - Status webhooks move each recipient to `sent`, `delivered`, `read` or `failed` (with Meta's error). The broadcast ends `done` when nothing is pending. - `cancel(id)` turns the pending recipients into `cancelled`. Messages already sent stay sent. ## Billing pauses and limits Meta bills every delivered message directly to the WhatsApp Business account that owns the number, by category and the recipient's country. Sendo moves no money and never copies Meta's rates: see [Meta's official pricing](https://developers.facebook.com/docs/whatsapp/pricing). - **No payment method (Meta 131042):** the broadcast pauses with `pauseReason: "billing"` and the number's `billingReady` becomes `false`. Pending recipients stay pending. Once the business adds a payment method and Meta confirms it (the tick re-checks billing hourly, or call `sendo.accounts.refreshBilling(id)`), the next tick resumes the broadcast by itself. - **Rate or tier limits (130429 and the other rate-limit codes):** that minute's pass stops and the recipient stays pending; the next tick continues. Meta also caps how many unique contacts a number can reach per day by its messaging tier. - **Template no longer approved:** the broadcast stops as `failed` and its pending recipients are cancelled. ## The Broadcasts screen (`broadcasts`) ```shell npx shadcn add @sendo/broadcasts ``` The Broadcasts screen at `/inbox/broadcasts` (admins only): 1. **New broadcast:** pick a number, an approved template (with its preview and the category rule), its variables, one of your app's contact lists or filters, and the rate. 2. **Review broadcast:** total, eligible and skipped counts by reason, the estimated time, and the cost note linking to Meta's pricing. "Send to N contacts" is disabled when nobody is eligible. 3. **Progress:** counts per state (refreshed every 5 seconds while sending), per-recipient results with Meta's error on failed rows, "Cancel broadcast", and a notice with a link to add a payment method when the broadcast is paused for billing. Before using it, edit `app/(protected)/inbox/broadcasts/broadcasts-config.ts`: implement `requireUser()` (it throws until you do) and return your audiences from `broadcastAudiences`. Recipients are always resolved on the server from the audience id; the browser never sends a phone list. The screen also needs the tick route from `workflows`. Details in the item's `AGENTS.md`. The screen renders in English or Spanish: the page passes `locale={sendoLocale}` from `lib/sendo-ui.ts`, and counts and dates follow the optional `formatLocale`. Override single texts with the `strings` prop or edit `components/sendo/broadcasts/strings.ts`; see [Languages](https://sendo.quami.dev/docs/languages). --- # Para agentes URL: https://sendo.quami.dev/docs/agents-docs > Docs for coding agents: the sendo mcp server (one-line setup for Claude Code and Cursor), llms.txt, AGENTS.md in every registry item and doctor --json. Sendo is built so a coding agent (Claude Code, Cursor, …) never has to guess the API. Four sources, all generated from the same code and docs: | Source | What it gives the agent | | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `sendo mcp` | An MCP server inside the installed package: docs search, exact signatures, registry items, doctor. Always matches the version in `node_modules`. | | [llms.txt](https://sendo.quami.dev/llms.txt) · [llms-full.txt](https://sendo.quami.dev/llms-full.txt) | The docs index, and every page as Markdown with the code samples inlined (latest release). | | `components/sendo//AGENTS.md` | Installed with each registry item: what it is, files safe to edit, what not to touch and why, how it connects, how to verify it. | | `npx @quami/sendo doctor --json` | Every setup check as JSON; each failing check has `fix: { kind, detail, docsUrl }`. | ## Setup Run it from your app's root, where `@quami/sendo` is installed. **Claude Code** (writes `.mcp.json`, so the whole team gets it): ```shell claude mcp add --transport stdio --scope project sendo -- npx -y @quami/sendo mcp ``` **Cursor**: add this to `.cursor/mcp.json` (or `~/.cursor/mcp.json` for every project): ```json title=".cursor/mcp.json" { "mcpServers": { "sendo": { "command": "npx", "args": ["-y", "@quami/sendo", "mcp"] } } } ``` Or open the install link `cursor://anysphere.cursor-deeplink/mcp/install?name=sendo&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBxdWFtaS9zZW5kbyIsIm1jcCJdfQ==` in Cursor. Any other MCP client: command `npx`, args `-y @quami/sendo mcp`, transport stdio. The server writes nothing but protocol messages to stdout and exits when the client closes it. ## Tools | Tool | Input | Returns | | --------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `search_docs` | `{ query, limit? }` | BM25 search over every docs page, by section: title, URL, a `read_doc` reference (`slug#anchor`) and a snippet. | | `read_doc` | `{ slug }` | One page as Markdown (`quickstart`), or one section (`cli#checks`). | | `api_reference` | `{ symbol }` | Declaration, JSDoc and import path of any export of `@quami/sendo` and its subpaths. Accepts `createSendo`, `InboxApi.reply` and instance paths like `sendo.inbox.reply`. Typos get "Did you mean". | | `list_registry_items` | none | Every `@sendo/*` item with its description, install command and where its `AGENTS.md` lands. | | `get_registry_item` | `{ name }` | Files and install targets, dependencies, env vars, post-install notes and the item's `AGENTS.md`. | | `run_doctor` | `{ account?, url? }` | Same JSON as `sendo doctor --json`, for the app in the working directory. Read-only. | All tools are read-only. Secrets from the environment are scrubbed from every response. ## Where the data comes from - **Docs:** `docs/docs.json` in the package, generated from these MDX pages (`pnpm --filter web build:llms`), the same source as `llms.txt`. - **API reference:** `dist/api.json`, generated at package build from the published `.d.ts` files, so it is exactly the API you have installed. - **Registry:** `dist/registry.json`, the registry manifest plus every item's `AGENTS.md`, also generated at build. ## run_doctor and your adapter `run_doctor` (like `sendo doctor`) loads `.env.development.local`, `.env.local` and `.env` from the working directory and builds the Supabase adapter from `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY`. If your app uses another adapter (Drizzle, Prisma, your own), export it from a module without Next.js imports and pass it with `--config`: ```ts title="lib/sendo-adapter.ts" import { supabaseAdapter } from "@quami/sendo/adapters/supabase"; import { createClient } from "@supabase/supabase-js"; // Or your Drizzle / Prisma / custom SendoAdapter. Relative imports only: no `@/` aliases. export const adapter = supabaseAdapter( createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!), ); ``` ```shell claude mcp add --transport stdio --scope project sendo -- npx -y @quami/sendo mcp --config lib/sendo-adapter.ts npx @quami/sendo doctor --config lib/sendo-adapter.ts ``` ## AGENTS.md in every item Every registry item ships an `AGENTS.md` (installed at `components/sendo//AGENTS.md`) with the same sections: an intro saying what the item is, **Files** (which ones are yours to edit), **Don't touch** (what lives in npm and why), **Connects to** (env vars, `createSendo` hooks, database hooks, other items) and **Verify** (how to check it works). Items with UI also ship `MOTION.md` next to it. `get_registry_item` returns the same text before you install anything. --- # Migrating from Respond.io or Kapso URL: https://sendo.quami.dev/docs/migrating-from-respond-io-kapso > Move a WhatsApp number, contacts and workflows from a hosted inbox into your own app with Sendo. Respond.io and Kapso are hosted inboxes. Sendo does the same job inside your app: the conversations land in your database and the inbox is a screen of your product. Migrating is mostly about the WhatsApp number, because everything else is yours to import. > **Note: Plan the cut-over** > > A WhatsApp number can only be connected to one Cloud API app at a time. Expect a short window in which messages go to neither tool, and do it when your traffic is low. ## Concepts | Respond.io / Kapso | Sendo | | -------------------------------------- | ---------------------------------------------------------------------------------- | | Workspace / project | Your app; organizations are `tenantId` | | Channel (WhatsApp number) | `sendo_accounts` row ([Numbers and tenants](https://sendo.quami.dev/docs/numbers-and-tenants)) | | Contact | `sendo_contacts`, linked to your own record through `linkContact` / `appRef` | | Inbox, assignment, notes | `inbox` and `sendo.inbox` ([Team inbox](https://sendo.quami.dev/docs/team-inbox)) | | Users and roles | Your users; Sendo asks `resolveRole`, `resolveUsers`, `resolveAssignees` | | Saved replies, tags | Snippets and tags | | Workflows, business hours, auto-assign | Rules ([Workflows](https://sendo.quami.dev/docs/workflows)); anything else is your code (`sendo.on(...)`) | | AI agent | `sendo.agent({...})` with any model ([Agents and models](https://sendo.quami.dev/docs/agents-and-models)) | | Broadcasts | `sendo.broadcasts` ([Broadcasts](https://sendo.quami.dev/docs/broadcasts)) | | Reports | `sendo.metrics` and the Metrics screen ([Metrics](https://sendo.quami.dev/docs/metrics)) | ## 1. Install Sendo next to the old tool Follow the [quickstart](https://sendo.quami.dev/docs/quickstart) with a **test number** first, so the webhook, the agent and the inbox are proven before you touch the real number. ## 2. Move the number - **If you own the Meta app and the WhatsApp Business account** (you created them, or the old tool connected through your own app): point the app’s webhook at `https:///api/sendo/webhook`, add the number with `npx @quami/sendo accounts add`, and run `npx @quami/sendo doctor`. Your templates stay, because they belong to the WhatsApp Business account. - **If the old tool is the Meta Tech Provider or BSP**: disconnect the number there, then connect it to your app through [Embedded Signup](https://sendo.quami.dev/docs/embedded-signup) or Meta’s phone number migration flow. Templates belong to the old WhatsApp Business account; if the number moves to a new one, re-submit them and wait for approval before you need them. Check Meta’s current documentation for number migration before you start; the steps depend on who owns the business account. ## 3. Import contacts and consent Export contacts from the old tool (CSV) and upsert them with the adapter or your own SQL (`sendo_contacts`: account, phone, profile name). Bring marketing consent along: record it with `sendo.contacts.setOptIn(contactId, true, { source: "import" })`, because [broadcasts](https://sendo.quami.dev/docs/broadcasts) of MARKETING templates only go to contacts who opted in. ## 4. Conversation history Sendo doesn’t import another tool’s message history. Keep the export as an archive, or insert it yourself with the adapter if your team needs it in the thread. New conversations start in Sendo from the cut-over. ## 5. Rebuild workflows as rules Business hours, routing by keyword, auto-assign, escalation and auto-close are [rules](https://sendo.quami.dev/docs/workflows), editable on the Reglas screen or kept in code with `sendo rules sync`. Anything more specific is a few lines of your own code on Sendo events. --- # Roadmap URL: https://sendo.quami.dev/docs/roadmap > What already shipped, what Sendo Connect is and isn’t, when it opens, and the channels still to come. Sendo is a library. Your messages, contacts and media live only in your own database, and that doesn’t change on this page. ## Shipped These items were on the V2 plan and are now part of `@quami/sendo` and the registry: - **Embedded Signup**: `sendo.accounts.completeEmbeddedSignup` and the `numbers` item ([Embedded Signup](https://sendo.quami.dev/docs/embedded-signup)). - **Broadcasts and opt-in**: `sendo.broadcasts`, `sendo.contacts.setOptIn` and the `broadcasts` item ([Broadcasts](https://sendo.quami.dev/docs/broadcasts)). - **Metrics**: SQL views, `sendo.metrics` and the `metrics` item ([Metrics](https://sendo.quami.dev/docs/metrics)). - **Drizzle and Prisma adapters**: `@quami/sendo/adapters/drizzle` and `@quami/sendo/adapters/prisma`, both passing `runAdapterContract` ([Adapters](https://sendo.quami.dev/docs/adapters)). - **Voice notes and cost**: the `transcribe` option and per-turn cost ([Voice notes and cost](https://sendo.quami.dev/docs/voice-notes-and-cost)). - **Sendo Connect (package side)**: the `connect` option, signup through Connect and the doctor checks ([Sendo Connect](https://sendo.quami.dev/docs/sendo-connect)). ## Sendo Connect Connect is the only paid piece. Quami acts as the Meta **Tech Provider** for your app, so you can offer “Connect your WhatsApp” without going through Meta’s business verification and app review yourself. ### What it is - **A signed webhook relay.** Meta sends webhooks for your numbers to Quami’s relay, which signs each one with your own secret (`X-Sendo-Signature`) and posts it to your app. Your app verifies it and fails closed. - **A signup API.** It completes Embedded Signup with Quami’s Meta app and returns the number’s token to your server once. - **A minimal panel.** API key, signing secret, your webhook URL, connected numbers and their billing status. - **Per-number billing.** Connect bills per connected number, per month ([Pricing](https://sendo.quami.dev/pricing)). Meta still bills messages to your customer’s WhatsApp Business account. ### What it isn’t - **Not an inbox.** The inbox is the `inbox` item in your app, reading your database. - **Not a message store.** Connect keeps no copy of your messages, contacts or media. - **Not user management.** Your staff, roles and tenants stay in your app. - **Not a sending proxy.** Your server sends straight to Meta with the token Connect returned at signup. Connect never sends on your behalf and doesn’t keep that token. It’s a piece, not a platform. Adding `connect` to `createSendo` is a configuration change; the package, the migrations and the registry items stay the same. ### The privacy cost With Connect, your customers’ webhooks **pass through Quami** before they reach you. That is a third party in the path of every inbound message: - A webhook body is held encrypted only while its delivery is pending, and deleted on success or when the 24-hour retry window ends. - Bodies are never logged. Only delivery metadata (timestamp, project, number, status code, attempt count, latency) is kept, for 7 days. - Quami’s Meta app is the one connected to your customers’ WhatsApp accounts. If that cost is too high for your data (health, for example), use your own Meta app. Doing Meta’s paperwork yourself stays free and first-class: see [Meta setup](https://sendo.quami.dev/docs/meta-setup) and [Embedded Signup](https://sendo.quami.dev/docs/embedded-signup). ### When it ships The package side already ships in `@quami/sendo`. Connect accounts open after Meta approves Quami as a Tech Provider; there is no date until that approval arrives. Until then, use [your own Meta app](https://sendo.quami.dev/docs/meta-setup). ## Planned > **Warning: Not available** > > Everything below is planned. None of it ships yet, there are no dates, and the names may change. ### More channels WhatsApp is the only channel today. Instagram and Messenger come next: the schema, the webhook dispatch and the inbox already carry a `channel`, so a new channel is new code in the package, not a migration in your app ([Channels](https://sendo.quami.dev/docs/channels)). The `Transport` interface stays swappable for the same reason, so a new channel or a new way to reach Meta doesn’t mean rewriting your app ([Transport](https://sendo.quami.dev/docs/api-reference#transport)). ### diff CLI `npx @quami/sendo diff` fetches the latest registry items and compares them with your installed files and `sendo_schema_meta.version`, with a changelog. Until then the package warns at startup when the installed schema is older than it expects.