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, so open that page in another tab.

Install the package

zod (v4) and ai (v6) are peer dependencies, so install them together:

shell
pnpm add @quami/sendo ai zod

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 lists them.

The installed route is three lines:

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.

components.json
{
  "registries": {
    "@sendo": "https://registry.sendo.quami.dev/r/{name}.json"
  }
}

Add the inbox

shell
npx shadcn add @sendo/inbox

The staff inbox at /inbox (see Inbox). The other screens (numbers, rules, broadcasts, metrics, links) are separate items you add the same way.

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):

supabase/migrations/<ts>_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
-- 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.

Set the environment variables

.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:

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:

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.",
});

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. Or insert the row yourself:

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');

Point the Meta webhook at your app

In the Meta App Dashboard, WhatsApp › Configuration: set the Callback URL to https://<app>/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).

Run doctor

shell
npx @quami/sendo doctor --url https://<app>/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.

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.

Next: API reference, Security and Retention.