Changelog
Releases of @quami/sendo. Registry items are copied into your app and don’t update themselves, so read the notes before you upgrade and run the migrations below.
Schema versions#
npx @quami/sendo doctor compares your database with the package. When it reports drift, copy each missing file from the schema registry item (or re-run npx shadcn add @sendo/schema) into supabase/migrations/ and run it, in order.
| Version | Migration file | Notes |
|---|---|---|
| v1 | 20261010000000_sendo_v1.sql | 0.1.0 |
| v2 | 20261011000000_sendo_v2.sql | 0.1.0 |
| v3 | 20261013000000_sendo_v3.sql | 0.1.0 |
0.1.1#
Patch Changes#
-
63e09df: Point every Sendo URL at
sendo.quami.dev.sendo.devbelongs to another company, and 0.1.0 used it as the default Connect host (DEFAULT_CONNECT_BASE_URL), so a Connect client withoutbaseUrlwould have sent its API key and the Embedded Signup code to a server Quami doesn't control.- Connect defaults to
https://connect.sendo.quami.dev(override withconnect.baseUrlorSENDO_CONNECT_BASE_URLas before). - Doctor fix links, schema changelog links and the package
homepagenow usehttps://sendo.quami.dev; the billing hint points athttps://app.sendo.quami.dev. - Registry items install from
registry.sendo.quami.dev.
Upgrade from 0.1.0 if you use Connect.
- Connect defaults to
0.1.0#
Minor Changes#
-
7f6d24d: First public release of
@quami/sendo(0.1.0), MIT licensed and published with public access.The package was previously developed under a different name. Apps that used the earlier copy need these changes:
- Install
@quami/sendoand import from@quami/sendoand its subpaths (/adapters/memory,/adapters/supabase,/adapters/contract,/next). The CLI isnpx @quami/sendo doctor. - Rename environment variables to the
SENDO_*names. The app secret isSENDO_META_APP_SECRET. - Re-install the registry items from the
@sendonamespace:npx shadcn add @sendo/webhook @sendo/schema @sendo/inbox. The items dropped theirsendo-prefix (sendo-inboxis nowinbox); the files they copy keep their paths (components/sendo/…,lib/sendo.ts), and the old names keep resolving on registry.sendo.dev until0.2.0.
Migration: copy
20261010000000_sendo_v1.sqlfrom the@sendo/schemaregistry item intosupabase/migrations/and run it (supabase db push, orsupabase db resetlocally). It creates thesendo_*tables (multichannel and team columns included), RLS policies and hooks (sendo_can_access_internal,sendo_can_access_conversation), functions (including thesendo_set_default_accountRPC) and the privatesendo-mediastorage bucket, at schema version 1. Then runnpx @quami/sendo doctorto confirm the schema version. - Install
-
72c1e61: Send as an organization and manage numbers from code and the CLI.
sendo.send.*,sendo.templates.syncandsendo.templates.listaccept{ tenant }next to{ account }. A tenant resolves to its default active number, else its only active number.AmbiguousAccountErrorlists only that tenant's candidates and carriestenantId;AccountNotFoundErrorcarriestenantIdtoo.- New
sendo.accounts:list,get,add,setDefault(atomic swap, one default per tenant),setLabel,pauseAgent,setAgentMode,disconnect(keeps the data, promotes the tenant's only remaining number, callsdeleteToken, unsubscribes Embedded Signup numbers from the WABA) andpurge(deletes a disconnected account, its data and its media objects).refreshBillingandcompleteEmbeddedSignupare declared and land in a later release. - New config keys
storeToken,deleteToken,metaandcoexistence, and new eventsaccount.default,account.disconnectedandaccount.billing. - New
AccountStateError(ACCOUNT_STATE). - CLI:
sendo accounts list [--tenant-id] [--json],sendo accounts set-default <id>andsendo accounts disconnect <id>. SendoAdaptergainsaccounts.setDefault,accounts.update,accounts.reactivate,accounts.purgeandstorage.remove. Custom adapters must implement them and pass the contract suite.
No schema change: schema v1 already has the columns and the
sendo_set_default_accountfunction. -
72c1e61: Agents per tenant, agent pause and WhatsApp Business app coexistence.
AgentDefinitiongainstenants?(tenant ids,nullfor no tenant, or(tenantId) => boolean), ANDed withaccounts, so one definition serves many organizations; the registration overlap check considers both lists.mode?: "auto" | "draft"is declared (draft behaviour lands with the team inbox).- No agent turn runs while the number (
accounts.pauseAgent) or the conversation is paused, or while a rule holds the agent. Checked when the conversation lock is taken, so a running turn finishes. Inbound messages are still stored and still bump unread and the window. - The webhook parser handles
smb_message_echoes(ParsedEcho,kind: "echo") andhistory(ParsedHistory,kind: "history"); newparseChanges(body)returns every handled change (parseWebhookstill returns onlymessageschanges). - Echoes are stored as sent outbound messages (
sender: "human",senderRefandmetadata.origin"business_app"), deduplicated by Meta id, update activity but never the 24-hour window, and pause the agent onagentconversations (coexistence.pauseAgentOnPhoneReply, default true) with aconversation.agent_pausedevent by"business_app". - With
coexistence.importHistory,historymessages are stored withmetadata.imported = trueand never trigger the agent; otherwise they are acknowledged and ignored.
Subscribe your Meta app to the
smb_message_echoes,historyandsmb_app_state_syncwebhook fields for coexistence numbers. No schema change. -
72c1e61: Broadcasts and marketing opt-in.
sendo.broadcasts.preview/create/get/list/recipients/cancel({ actor })(needsbroadcasts.manage): send one APPROVED template to many contacts. MARKETING templates go only to contacts withmarketingOptIn = true; UTILITY and AUTHENTICATION templates go to every contact who hasn't explicitly opted out. Ineligible recipients are storedskippedwithno_opt_in/opted_out/invalid_phone; phones become contacts.ratePerMinutedefaults to 60.sendo.maintenance.tick()now sends broadcasts: up toratePerMinuterecipients per broadcast and tick, withmetadata.broadcastIdon the stored message. Status webhooks move recipients forward (sent → delivered → read, or failed with Meta's error). Meta 131042 pauses the broadcast (pauseReason: "billing") and marks the numberbillingReady = false; the tick resumes it oncebillingReadyis true. A rate limit (130429) stops the pass and leaves recipientspending. Broadcast messages never move a conversation tohumanor run the agent.sendo.contacts.setOptIn(contactId, optIn, { source? })records an explicit decision and emits the newcontact.opt_inevent.- Inbound opt-out keywords (
optOut: { keywords?, confirmation? }, defaultBAJA,STOP,ALTO,CANCELAR): an exact match after trimming and ignoring case and accents opts the contact out, emitscontact.opt_in, sends the confirmation inside the 24-hour window and skips rules and the agent turn. - Adapter:
contacts.getByPhone(accountId, phone),contacts.setOptIn(id, { marketingOptIn, optInAt })andbroadcasts.listRecipients({ broadcastId, status?, cursor?, limit }). Custom adapters must implement them (covered byrunAdapterContract).
No schema change (uses the v3 tables).
-
20d1fa7: The package now carries the whole Sendo Connect service contract.
signConnectBody(rawBody, secret, timestamp?)accepts one secret or a list ([old, new]) and adds onev1=per secret, in order, for signing secret rotation;verifyConnectSignaturealready accepts a header when anyv1matches. A blank secret or an empty list now throwsTypeError.- The main entry exports the Connect client and its contract:
createConnectClient,ConnectClient,ConnectClientOptions,ConnectRequestError,ConnectSignupRequest,ConnectSignupResult,ConnectWebhookStatus,CONNECT_SIGNUP_STEPS,ConnectSignupStep, and the newCONNECT_ERROR_CODES/ConnectErrorCodeandCONNECT_TEST_EVENT_OBJECT("sendo_test"). - New
ConnectClient.disconnectNumber(phoneNumberId)(POST /v1/numbers/{phoneNumberId}/disconnect) andConnectRequestError.isProjectSuspended. A403 PROJECT_SUSPENDEDis no longer reported as a rejected API key. sendo.accounts.disconnectandsendo accounts disconnectnotify Connect when a number withonboardedVia: "connect"is disconnected andconnect(orSENDO_CONNECT_API_KEYfor the CLI) is configured. It's best effort: a failure is a redacted warning and the number is still disconnected.- With
connect,sendo.handleWebhookanswers a relay-signed{"object":"sendo_test"}(the panel's "Send test event") with 200 andCache-Control: no-store, storing and emitting nothing. Unsigned or badly signed requests are still 401. sendo doctorreports a403 PROJECT_SUSPENDEDonconnect:keyas "Connect project suspended — update payment at app.sendo.dev", with abillingfix.
-
72c1e61: Sendo Connect, package side: use Quami's Meta app with configuration only.
- New
connect?: { apiKey, signingSecret, baseUrl? }increateSendo(baseUrldefaults tohttps://connect.sendo.dev;DEFAULT_CONNECT_BASE_URL,ConnectOptions,ConnectConfigexported). Both secrets are redacted like tokens. Withconnect,appSecretis optional;connectwith a non-meta-direct transport is aConfigError. - Webhooks: with
connect,handleWebhookonly acceptsX-Sendo-Signature: t=<unix>,v1=<hex hmac_sha256(signingSecret, t + "." + rawBody)>(5-minute tolerance, constant-time, severalv1allowed for rotation); Meta'sX-Hub-Signature-256is not accepted. Fails closed with 401 and stores nothing. Withoutconnectthe Meta path is unchanged. New exportsCONNECT_SIGNATURE_HEADER,CONNECT_SIGNATURE_TOLERANCE_SEC,signConnectBody,verifyConnectSignature. completeEmbeddedSignupwithconnectPOSTs{ code, wabaId, phoneNumberId, coexistence, pin }to{baseUrl}/v1/signup/complete(BearerapiKey), receives{ token, displayPhone }and continues fromstoreTokenwith the same compensation; accounts getonboardedVia: "connect".meta.appIdand the app secret are not needed. Connect's Meta errors keep the usual codes and steps; a rejected key isCONFIG_ERROR; other Connect failures areSIGNUP_FAILEDwith the new step"connect". Sending still goes directly to Graph.sendo doctor: whenSENDO_CONNECT_API_KEY/SENDO_CONNECT_SIGNING_SECRETare set it requires both (notSENDO_META_APP_SECRET) and addsconnect:key,connect:webhookandconnect:delivery(viaGET {baseUrl}/v1/projects/current/webhook,SENDO_CONNECT_BASE_URLto override), each with afix.
No schema change. Registry:
webhookdeclaresSENDO_CONNECT_API_KEYandSENDO_CONNECT_SIGNING_SECRETand ships the opt-inconnectwiring commented inlib/sendo.ts. - New
-
20d1fa7: Disconnected numbers leave the default inbox, and a number's history can be exported.
- Behaviour change (minor while 0.x):
sendo.inbox.listandsendo.inbox.countnow leave out the conversations of disconnected numbers. PassincludeInactiveAccounts: trueto include them; a query with that number'saccountIdor a non-blanksearchstill finds them. - New
sendo.accounts.export(id, { actor }): anAsyncIterable<string>with the chunks of one JSON document (SendoExportDocumentV1,format: "sendo-export",version: 1) holding the number's contacts, tags, conversations, messages, internal notes, assignments, system events (with theirmetadata) and media references (storageKey, a URL signed for 7 days,missing). It works on connected and disconnected numbers, streams page by page, and is a snapshot as ofexportedAt. Admins only (numbers.manage): any other actor getsForbiddenErrorwhen calling it, before any chunk. - New
exportFileName(displayPhone, exportedAt)→sendo-export-50222345600-2026-10-12.json, and theSendoExport*types. - New CLI command
sendo accounts export <id> [--out <file>]: writes the same document to the file (or stdout), with progress on stderr. ConversationFiltergainsexcludeAccountIds(memory, Supabase, Drizzle and Prisma adapters; covered by the adapter contract suite, which also gains an export case). Custom adapters should implement it.
- Behaviour change (minor while 0.x):
-
72c1e61: Drizzle adapter: run Sendo on any Postgres 15+ (Neon, RDS, Cloud SQL, self-hosted) without Supabase.
drizzleAdapter({ db, media? })from@quami/sendo/adapters/drizzletakes adrizzle-orm/node-postgresordrizzle-orm/postgres-jsdatabase (optional peerdrizzle-orm) and implements the wholeSendoAdapter, including snippets, tags, rules, broadcasts andmetrics.*(throughMETRICS_SQL). It passes the adapter contract suite on both drivers.- Plain SQL over the
sendo_*tables; what the Supabase functions do (forward-only status updates and placeholders,markSentmerges, locks, unread increments, the default-number swap, metadata and rule-state merges) runs as single statements or transactions with the same advisory locks, so no function grants are needed. - Unmapped database failures throw
DrizzleAdapterError(operation,code,constraint,detail); the documented constraints still map toConstraintViolationError. - There is no default media store: pass
media(e.g.s3Storage) here or tocreateSendo. - On this adapter, access control is the package's role checks only (RLS applies to Supabase browser clients).
Setup: install the
schemaanddrizzle-schemaregistry items; on plain Postgres runlib/db/sendo-postgres-prelude.sqlonce, then apply20261010000000_sendo_v1.sql,20261011000000_sendo_v2.sqland20261013000000_sendo_v3.sqlin order. See https://sendo.dev/docs/adapters#drizzle. - Plain SQL over the
-
72c1e61: Embedded Signup and billing state.
sendo.accounts.completeEmbeddedSignup({ code, tenantId, wabaId, phoneNumberId, coexistence?, pin?, label? })exchanges the code, subscribes the app to the WABA, registers the number (skipped for coexistence; the PIN is never stored), stores the token throughstoreToken, inserts or reconnects the account (onboardedVia: "embedded_signup"), makes it the tenant's default when it is the first, and checks billing. A failure afterstoreTokencallsdeleteToken, unsubscribes the app (best effort) and leaves no row. NeedsstoreToken,meta.appIdand the app secret (ConfigErrorotherwise).- New
EmbeddedSignupErrorwith codesSIGNUP_CODE_EXPIRED,META_PERMISSION_MISSING,PIN_CONFLICTandSIGNUP_FAILED(plusstep,metaCode,subcode,fbtraceId,details). A number connected elsewhere isDuplicateAccountError(ACCOUNT_EXISTS), raised before the code is spent. sendo.accounts.refreshBilling(id)reads the WABA funding field (primary_funding_id): present →billingReady: true, absent →false, Meta error → unchanged. It always setsbillingCheckedAtand emitsaccount.billingon change.readWabaBillingReady,billingReadyFromWabaandWABA_FUNDING_FIELDare exported.MetaApiErrorgainshint:billing_not_ready(131042),token_invalid(190),window_closed(131047),rate_limitedornull. A send failing with 131042 setsbillingReady = falseand emitsaccount.billing; sends are still attempted whenbillingReadyisfalse.GraphClientgainsexchangeCode,subscribeApp,registerPhoneandgetWaba.sendo doctoraddsaccount:<id>:billing, a warning (warning: true, shown asAVISO) with abillingfix when the value isfalseor unknown. Warnings don't change the exit code.
No schema change.
-
1eb0213: The package speaks English to developers.
- Breaking (minor while 0.x):
sendo --help, every CLI message and prompt,sendo doctorcheck labels, details andfix.detail, theaccounts listtable headers,rules syncoutput and every thrown package error are now English. The doctor table usesOK/FAIL/WARNand-> Fix:. Errorcodes, checkids andfix.kinds are unchanged, so code that branches on them keeps working; code that matched Spanish message text must switch to the codes. - Rule validation messages (
@quami/sendo/rulesschemas andsendo rules sync) andschemaDriftMessage("Your database is at schema version N and Sendo expects M. Run these migrations: …") are English. The registry rules screen shows the schema-declared messages as they are, so they are English there too. - The agent system prompt is English and tells the model to reply in the contact's language. The handoff tool keeps its name (
transferir_a_humano) andmotivoinput; its description is English and its result is{ handedOff, status, assignees }. History labels the model reads are English ([operator],[voice note],[untranscribed voice note],[image], …;HUMAN_PREFIXandUNTRANSCRIBED_VOICE_NOTEhold the new values), and the current date is formatted neutrally (en-US) with the IANA time zone. sendo mcpsearch drops the Spanish stop words (the docs are English).- End-customer WhatsApp text is unaffected here; it follows
createSendo({ locale }).
- Breaking (minor while 0.x):
-
72c1e61:
sendo.links:create({ account?, tenant?, text?, source })returns awa.meclick-to-chat link whose prefilled text ends with[#source]({ url, code, phone, text }; resolves the number likesend.*, writes nothing;sourcemust match^[a-z0-9-]{1,32}$).sources({ actor, account?, from?, to? })counts conversations per source in the actor's tenant (admin only,numbers.manage). New pure helperbuildWhatsAppLink({ phone, text?, source })andLINK_SOURCE_RE. Thewidgetsregistry item adds the links screen (QR with PNG/SVG download, chat bubble snippet, sources table) and<SendoChatBubble />. -
1eb0213: Add
createSendo({ locale })("en" | "es", default"en") and structured system events.- Breaking (minor while 0.x): the built-in end-customer messages are now English by default. The handoff message is "Connecting you with someone from the team." and the opt-out confirmation is "Done, we won't send you promotional messages anymore. If you change your mind, message us." To keep the previous Spanish text, pass
locale: "es". An explicithandoffMessage,handoff.messageoroptOut.confirmationstill wins. - New exports:
SendoLocale,BUILT_IN_MESSAGES(the one table of built-in messages per locale),BuiltInMessages,SystemEventNameandSYSTEM_LINE_EVENTS.DEFAULT_HANDOFF_MESSAGEandDEFAULT_OPT_OUT_CONFIRMATIONstay exported and now hold the English values. - System lines keep
metadata.eventand now store every value their sentence uses as params (reason,assignedTo,assignedToName,byName,ruleKey,queue,tags,minutesWaiting,afterDays,agentResumeFrom). The storedtextis English.InboxSystemLinegainsparams, so a client can render each line in the viewer's language fromevent+paramsand fall back totext. Rows written earlier (Spanishtext, missing or unknown event) come through unchanged. - A route rule that hands off without a
reasonnow storesRule «<key>»as the handoff reason (wasRegla «<key>»).
- Breaking (minor while 0.x): the built-in end-customer messages are now English by default. The handoff message is "Connecting you with someone from the team." and the opt-out confirmation is "Done, we won't send you promotional messages anymore. If you change your mind, message us." To keep the previous Spanish text, pass
-
72c1e61:
sendo mcp: an MCP server for coding agents, bundled with the installed version.npx -y @quami/sendo mcpserves, over stdio,search_docs(BM25 over the docs),read_doc(a page orslug#anchorsection),api_reference(declaration, JSDoc and import path of any export, e.g.createSendo,InboxApi.reply,sendo.inbox.reply),list_registry_items,get_registry_item(files, env vars and the item'sAGENTS.md) andrun_doctor(the same JSON assendo doctor --json, read-only). Setup for Claude Code:claude mcp add --transport stdio --scope project sendo -- npx -y @quami/sendo mcp; Cursor: see https://sendo.dev/docs/agents-docs.- The package now ships
docs/docs.json, and the build writesdist/api.json(from the published.d.ts) anddist/registry.json(registry manifest plus every item'sAGENTS.md). sendo doctor --config <path>andsendo mcp --config <path>use theadapterexported by that module instead of the Supabase adapter built from env (for Drizzle, Prisma or custom adapters).@modelcontextprotocol/sdkis a new runtime dependency, loaded only by the CLI (the library entry never imports it).- Clearer
FORBIDDENandACCOUNT_STATEmessages: each now says what to do next (configureresolveRole, checkcan(actor, …), reconnect the number).
No schema change.
-
72c1e61: Pluggable media storage.
createSendo({ media })takes aMediaStore(put,get,signedUrl,delete, optionalexists); the pipeline's media download, the signed URLs ofsendo.inbox.thread, agent vision,sendo.accounts.purgeandsendo doctor's bucket check all go through it. Withoutmediathe adapter's default store is used (supabaseAdapterkeeps its bucket,memoryAdapterits in-memory map); with neither, inbound media is stored asfailedand doctor reports aconfigfix.New subpaths:
@quami/sendo/storage/supabase:supabaseStorage({ client, bucket }), the storesupabaseAdapternow uses.@quami/sendo/storage/s3:s3Storage({ client, bucket })for AWS S3, Cloudflare R2 and other S3-compatible stores (optional peers@aws-sdk/client-s3and@aws-sdk/s3-request-presigner), withS3StorageError.@quami/sendo/snippetsand@quami/sendo/rules: client-safe helpers (snippet search and variables,SNIPPET_BODY_MAX_LENGTH,TAG_NAME_MAX_LENGTH,SNIPPET_VARIABLE; rule schemas and types) that don't pull the server runtime into browser bundles.@quami/sendo/adapters/drizzleand@quami/sendo/adapters/prisma: the Drizzle and Prisma adapters (optional peersdrizzle-orm,@prisma/client), described in their own entries.
@quami/sendo/adapters/contractaddsrunMediaStoreContract, and the main entry exportsmetricsFromSql,metricsViewStatementsand theMediaStoretype.sendo doctor --configreads an optionalmediaexport next toadapter.Breaking for custom adapters:
SendoAdapter.storageis now an optionalMediaStore: renameremovetodeleteandbucketExiststoexists. Adapters without media storage can omit it and rely oncreateSendo({ media }).The Supabase adapter now stamps broadcast recipients'
updated_atwith one clock on insert and update, sonextPendingordering no longer mixes the database and app clocks. -
72c1e61:
sendo.metrics:daily,byAssignee,firstResponse,aiResolution,bySourceandcost, each taking{ actor, from, to, account?, tagIds? }. Tenant-scoped to the actor; needsmetrics.view(supervisors and admins), andcostneedscosts.view(admins). For actors withoutcosts.view,dailyreturnscostCents,tokensInandtokensOutasnull.byAssigneeaddsnameandavatarUrlfromresolveUsers.accountaccepts an account id or Meta phone number id of the tenant (disconnected numbers included);tagIdsis an any-match tag cut. New helpersmetricsDayRange({ from, to, timeZone })(calendar days, both included, to local-midnight instants) andmetricsLocalDay(timeZone, at?), and the typesMetricsDailyandMetricsAssignee. Themetricsregistry item adds the "Métricas" screen at/inbox/metrics. -
72c1e61: Prisma adapter: run Sendo on any Postgres 15+ through your app's
PrismaClient.prismaAdapter({ prisma, media?, schema?, transaction? })from@quami/sendo/adapters/prisma(optional peer@prisma/client; Prisma 5+, Prisma 7 with a driver adapter such as@prisma/adapter-pg) implements the wholeSendoAdapter, including snippets, tags, rules, broadcasts andmetrics.*(throughMETRICS_SQL), and passes the adapter contract suite.- Raw SQL with bound parameters through
$queryRawUnsafe/$executeRawUnsafe; what the Supabase functions do (forward-only status updates and placeholders,markSentmerges, locks, unread increments, the default-number swap, rule reordering, purge, metadata and rule-state merges) runs as single statements or interactive$transactions with the same advisory locks. Your generated models are never used, so their names don't matter. schema(defaultpublic) points the adapter at the Postgres schema that holds thesendo_*tables.- Unmapped database failures throw
PrismaAdapterError(operation,code); the documented constraints still map toConstraintViolationError. - There is no default media store: pass
media(e.g.s3Storage) here or tocreateSendo. - On this adapter, access control is the package's role checks only (RLS applies to Supabase browser clients).
Setup: install the
schemaandprisma-schemaregistry items; on plain Postgres runprisma/sendo-postgres-prelude.sqlonce, then apply20261010000000_sendo_v1.sql,20261011000000_sendo_v2.sqland20261013000000_sendo_v3.sqlin order (each as its own Prisma migration), and includeprisma/sendo.prismain your Prisma schema. See https://sendo.dev/docs/adapters#prisma. - Raw SQL with bound parameters through
-
1eb0213: Remove the reserved hosted-transport stub.
Its factory and options type are no longer exported from
@quami/sendo: every method only threwNotImplementedError, and no hosted mode that stores your data is planned. TheTransportinterface andmetaDirectare unchanged, andNotImplementedErrorstays (a transport withoutuploadMediaormarkRead, ortemplates.syncon a transport that isn'tmetaDirect). The roadmap now describes Sendo Connect and the channels still to come. -
72c1e61: Rules engine,
sendo.maintenance.tick(), source attribution andsendo rules sync.- Five rule types (
business_hours,route,auto_assign,escalate,auto_close) with zod schemas exported from the main entry (ruleParamsSchemas,ruleDefinitionSchema,parseRuleParams, ...). Rules live insendo_rules; the engine reads them per tenant (cached 30 s, refreshed on writes throughsendo.rules). sendo.rules.list/upsert/toggle/reorder/delete({ actor })needrules.manageand recordupdatedBy;listflags stored rules whose params no longer validate (valid,error).- Pipeline: the first message of a new or reopened conversation sets
conversation.sourceandmessage.metadata.sourcefrom a trailing[#code]or a Click-to-WhatsAppreferral(ad:<source_id>), thenrouterules run (first match wins). Inbound messages outside business hours get the after-hours message once per closed period, only inside the 24-hour window.appliesTo: "all"holds the agent. sendo.maintenance.tick({ now?, budgetMs? })(locktick, 120 s) hands held conversations back to the agent when hours open, escalates, auto-closes, re-checks billing hourly for accounts withbillingReady = false, and prunes events once a day. It returns aTickReportwith per-step counts. Call it every minute from a cron route.- Every rule action writes a system line in the thread and emits
rule.applied(plusconversation.status,conversation.assignedorescalation) withby: "rule:<key>". - CLI:
sendo rules sync --config <path> [--force] [--tenant <id>]loadsrulesfrom a JS/TS module and inserts missing rules with origincode; existing rules are only overwritten with--force.jitiis a new runtime dependency, loaded only by the CLI.
No schema change (uses the v2 tables).
- Five rule types (
-
72c1e61: Schema v2: storage for the team inbox (snippets, tags and rules).
Migration: copy
20261011000000_sendo_v2.sqlfrom theschemaregistry item intosupabase/migrations/and run it (supabase db push, orsupabase db resetlocally). Re-installing the item (npx shadcn add @sendo/schema) adds the file too. It is additive: it createssendo_snippets,sendo_tags,sendo_conversation_tagsandsendo_ruleswith RLS select policies throughsendo_can_access_tenant, the optional hooksendo_current_user_id()(personal snippets are only readable by their owner; defaults toauth.uid()::text, replace it if your app's user ids are not Supabase Auth ids), the service-role functionssendo_merge_rule_state,sendo_escalation_candidatesandsendo_count_by_source, and sets schema version 2. Until it runs,sendo doctorand the startup check report the drift.- New entities
Snippet,TagandStoredRule, and enumsSNIPPET_VISIBILITIES,TAG_COLORS,RULE_TYPESandRULE_ORIGINS. SendoAdaptergains thesnippets,tagsandrulesnamespaces,conversations.count,countBySource,countByAssigneeandfindForTick,messages.updateandmessages.deleteDrafts.conversations.listacceptstagIds(any match),queue,visibleToandsearch. Custom adapters must implement them and pass the contract suite.SENDO_TABLES(checked byhealthCheckandsendo doctor) includes the four new tables.
- New entities
-
72c1e61: Schema v3: storage for broadcasts and the metrics views.
Migration: copy
20261013000000_sendo_v3.sqlfrom theschemaregistry item intosupabase/migrations/and run it (supabase db push, orsupabase db resetlocally). Re-installing the item (npx shadcn add @sendo/schema) adds the file too. It is additive: it createssendo_broadcastsandsendo_broadcast_recipientswith RLS select policies throughsendo_can_access_tenant(no write policies; deleting an account deletes its broadcasts), the five read-onlysecurity_invokerviewssendo_metrics_daily,sendo_metrics_by_assignee,sendo_metrics_first_response,sendo_metrics_ai_resolutionandsendo_metrics_by_source, and sets schema version 3. Until it runs,sendo doctorand the startup check report the drift.- New entities
BroadcastandBroadcastRecipient, and enumsBROADCAST_STATUSESandBROADCAST_RECIPIENT_STATUSES. SendoAdaptergains thebroadcastsnamespace (insert,get,list,update,insertRecipients,nextPending,updateRecipient,recipientByMessageId,cancelPending,counts), the read-onlymetricsnamespace (daily,byAssignee,firstResponse,aiResolution,bySource,cost, all taking{ tenantId, from, to, accountId?, tagIds? }) andmessages.pendingDraftConversations. Custom adapters must implement them and pass the contract suite.- Metrics days are calendar days in the account's timezone. First response ignores internal notes, drafts and imported history; AI resolution means the conversation was never handed off and no human wrote; cost per day and per AI agent comes from
cost_cents(drafts included). METRICS_SQL,METRICS_VIEWSandmetricsSqlParamsexport the same queries as parameterized SQL for Drizzle, Prisma or plainpgusers.SENDO_TABLES(checked byhealthCheckandsendo doctor) includes the two new tables.
- New entities
-
72c1e61:
sendo.snippetsandsendo.tags(schema v2). Snippets:list({ actor, accountId? })(team snippets plus the actor's personal ones, optionally only those usable on a number),get,upsert({ id?, shortcut, body, visibility?, accountId? }, { actor }),deleteandrender(snippetId, { actor, conversationId })→{ text, unresolved }, which fills the built-ins{{nombre}},{{telefono}},{{agente}}and the values of the newresolveSnippetVariables({ contact, conversation, account })config hook, leaving unresolved variables as written. Team snippets needsnippets.manage_tenant; personal ones are visible and editable only by their owner; shortcuts are unique per tenant and owner (ConstraintViolationError). Tags:list({ actor })for every role, andupsert({ id?, name, color }, { actor }),delete(also removes the tag from every conversation) andusagewithtags.manage; colors come fromTAG_COLORS. Browser-safe helperssearchSnippets(accent- and case-insensitive),findSnippetVariables,fillSnippetVariables,normalizeSearchTextandSNIPPET_SHORTCUT. Theinboxregistry item adds the composer with Responder / Nota interna, the/snippet picker with unresolved-variable highlighting, the tag picker, the assign menu and the snippets / tags managers; re-copyactions.ts,page.tsx,inbox.tsx,composer.tsxand the new component files. -
72c1e61: Team inbox operations: actor-based
sendo.inbox, notes, drafts, agent pause and turn cost.- Breaking: every
sendo.inbox.*method takes{ actor }fromsendo.actor(userId, tenantId)(needsresolveRole) instead oftenantId/userId, and enforces the role matrix: another tenant's conversation isRECORD_NOT_FOUND; a conversation the actor can't see, reassigning someone else, or pausing the AI without the role isFORBIDDEN.replyreturns anInboxMessage;markSeenreturns nothing. - New:
inbox.get,count,note,take,assign,returnToAgent,close,pauseAgent,approveDraft,discardDraft,assignees,tags.add/remove, andsendo.conversations.pauseAgent(id, paused, { by }). InboxConversationaddschannel,source,queue,assignee(fromresolveUsers),handoff { reason, by, at },agentPaused/agentPausedOn,hasPendingDraft,accountActiveandtags.InboxMessageaddsinternal,systemLine,draft,handoffAfterDraft,approvedBy,edited,origin,sourceandtokensIn/tokensOut/costCents(admins only).- Step-in: a person's reply leaves the status and the agent alone and discards the pending draft. Internal notes are never sent and never reach the model.
- Draft mode (
mode: "draft"or the number'sagentMode): the agent stores one pending draft per conversation instead of sending; approving after the 24-hour window throwsWindowClosedError. - Agent pause: no catch-up on resume; the agent answers from the next inbound message on.
conversation.agent_pausedfires on every change andconversation.assignedon every assignment change. - Handoffs, takes, assignments, returns to the AI, closes and pauses add a system line to the thread (
internal, sendersystem,metadata.kind = "system"). Handoffs useresolveAssignees(falling back toresolveHandoffTargets), and every entry intohumancalls the rules hook. - Every agent turn stores
tokensIn/tokensOut, andcostCentsfrom the provider's cost (OpenRouter), else the newAgentDefinition.pricing, elsenull. - A disconnected number makes
reply,noteandapproveDraftthrowAccountStateError(ACCOUNT_STATE); reads keep working. - Adapters:
messages.insertOutboundacceptsstatus: "sent"for rows that never go through the transport.
Re-copy
app/(protected)/inbox/actions.ts,page.tsxandcomponents/sendo/types.tsfrominbox, and configureresolveRoleinlib/sendo.ts. No schema change. - Breaking: every
-
72c1e61: Voice-note transcription and conversation cost.
createSendo({ transcribe: { model, timeoutMs? } })transcribes inboundaudiowithexperimental_transcribefromairight after the media download and before the opt-out check, rules,messageevent and agent turn (30 s limit, 25 MB max). The transcript goes into the messagetextwithmetadata.transcription = { model, ms }; on failuretextstaysnullandmetadata.transcription.erroristoo_large,timeout,no_audio,emptyorfailed. An invalidtranscribethrowsConfigError. ExportsTRANSCRIBE_TIMEOUT_MS,TRANSCRIBE_MAX_BYTES,TranscriptionError,UNTRANSCRIBED_VOICE_NOTE.- Agent history: inbound voice notes read as
[nota de voz] <transcript>, or[nota de voz sin transcribir](previously[audio]). InboxMessage.transcription: { model, ms, error } | null.- New
sendo.inbox.cost(conversationId, { actor })→{ turns, tokensIn, tokensOut, costCents, unpricedTurns }for the whole conversation (agent replies and drafts); needscosts.view(admins). - Re-copy the
inboxregistry item: agent replies show their cost and tokens on hover for admins, "Costo del agente" totals the whole conversation, and voice notes show their transcript or "Sin transcripción".
Patch Changes#
-
9d24d54: Consistent access errors across the team APIs. Every method that takes
{ actor }(sendo.inbox,snippets,tags,rules,broadcasts,metrics,links.sources) now refuses a missing or forged actor (no user id, unknown ornullrole) withForbiddenError(role: null, plus the capability the call needs) before reading anything;snippets.list,snippets.get,snippets.renderandtags.listused to answer such an actor. AnaccountIdfilter oninbox.list,inbox.count,snippets.list,rules.listandbroadcasts.listmust name a number of the actor's tenant (connected or not, by id or Meta phone number id), elseAccountNotFoundError, like every other account argument.inbox.tags.add/removerefuse an unknown tag or one of another tenant withRecordNotFoundError(entity: "tag"). The inbox list reads pending drafts with one batched query, so a draft older than the last 30 messages still shows. -
f9c15a0: Registry items drop the
sendo-prefix:npx shadcn add @sendo/webhook @sendo/schema, thennpx shadcn add @sendo/inbox(alsonumbers,workflows,broadcasts,metrics,widgets,drizzle-schema,prisma-schema). Installed files keep their paths (components/sendo/…,lib/sendo.ts,supabase/migrations/…), so apps that already installed the items change nothing. registry.sendo.dev still serves the oldsendo-*names until0.2.0.sendo doctorand the schema-version warnings point tonpx shadcn add @sendo/schema.sendo mcp:list_registry_itemsreturnsnpx shadcn add @sendo/<name>;get_registry_itemacceptsinbox,@sendo/inboxand the oldsendo-inbox.package.jsongainsrepository,homepageandbugs(github.com/quamidev/sendo, sendo.dev).