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.

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

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<AppUser> {
  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.
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:<name>" or "rule:<key>"
  params: Record<string, string | number | boolean | null>;
}

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

eventkind: "system" paramskind: "rule" params (always ruleKey)
handoffreason, assignedTo + assignedToName when one assigneequeue, tags (comma-separated), when the route sets them
assignedassignedTo, assignedToName (by === assignedTo: a take)assignedTo, assignedToName; minutesWaiting = escalated
unassignednonenone
returnednoneminutesWaiting
closednoneafterDays
agent_pausednonenone
agent_resumedagentResumeFrom (newest inbound message id)none
routednonequeue, tags
after_hoursnonenone
escalatednoneminutesWaiting

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:

VariableValue
{{nombre}}The contact's WhatsApp profile name
{{telefono}}The contact's phone
{{agente}}The acting user's name, from resolveUsers
anything elseresolveSnippetVariables({ 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.

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<string, string> = {};
    const saldo = await findBalance(contact.appRef);
    if (saldo !== null) values.saldo = saldo;
    return values;
  },
});
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
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 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.