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#

TypeParamsRuns
business_hourstimezone (IANA), schedule (mon…sun, each a list of ["HH:MM", "HH:MM"] ranges, end exclusive, 24:00 = midnight), holidays? (YYYY-MM-DD), outsideMessage, appliesToOn every inbound message and on the tick
routematch: { channel?, account?, source?, keywords? }, action: { queue?, tags?, to: "agent" | "human", reason? }On a new or reopened conversation; in order, first match wins
auto_assignqueue? (omitted = any queue, null = no queue), strategy: "least_open" | "round_robin"When a conversation enters human, inside hours
escalateafterMinutes (1–10 080, open minutes only), queue?, action: "reassign" | "notify" | "return_to_agent"On the tick
auto_closeafterDays (1–365) without activityOn 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:<id> for a Click-to-WhatsApp ad, see Links). The action sets the queue, adds tags (created in gray when missing) and hands off to a person with by: "rule:<key>" 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) and emits conversation.status, conversation.assigned or rule.applied with by: "rule:<key>"; 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). Rules are admin only (rules.manage): sendo.rules.* throws FORBIDDEN for other roles, and the screen shows "You don't have access to Rules".

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

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 <uuid>
  • 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.

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 <RulesScreen strings={{ ... }} /> or edit components/sendo/workflows/strings.ts; see Languages.

The screen calls sendo.rules, which you can also use directly:

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
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 <SENDO_CRON_SECRET> (Vercel's CRON_SECRET is accepted too). With no secret configured it always answers 401. The response is the TickReport JSON.

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 <secret>`, 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<Response> {
  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#

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
-- Supabase: Database → Extensions → enable pg_cron and pg_net. Keep the secret in Vault.
select vault.create_secret('<the SENDO_CRON_SECRET value>', 'sendo_cron_secret');

select cron.schedule(
  'sendo_tick',
  '* * * * *',
  $$
  select net.http_post(
    url := 'https://<your-domain>/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 is optional once the tick runs.