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.
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#
replysends as senderhuman(senderRef= the actor) and changes nothing else: anagentconversation stays with the AI, which sees the message as[operator] …on its next turn. A reply also discards the agent's pending draft.takeassigns the actor and moves the conversation tohuman.assign(id, userId | null)emitsconversation.assigned { from, to, by }.assignees(id)lists who the conversation can go to:resolveAssignees({ tenantId, queue, account })(falling back toresolveHandoffTargets), with names and avatars fromresolveUsers.- Handoffs, takes, assignments, returns to the AI, closes and pauses add a system line to the thread (
internal, sendersystem,metadata.kind = "system"). Rules add theirs withkind = "rule". See System events.
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:
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).
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 needssnippets.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(CONFLICTin 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.
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;
},
});// 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).
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 conversationsendo.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, withmetadata.approvedByandmetadata.edited. After the 24-hour window closed it throwsWindowClosedErrorand 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.