apps/core-api/src/modules/index.ts and are protected by a shared token.
Token verification
Both routes read?token= from the query string and pass it to ctx.smartsend.verifyWebhookToken.
- The secret is the env var
SMARTSEND_WEBHOOK_SECRET. - With the secret unset, every request is rejected.
- A mismatch fails with
UNAUTHORIZED, “invalid webhook token”. - It is one global token, shared by every studio and by the agent route. It is not a per-request signature, so the body is not authenticated. The token also travels in the URL, which means it can appear in proxy and access logs. Rotate it if a log leaks.
The studio inbound webhook
How SmartSend learns the URL
When a studio connects,connect in whatsapp.service.ts builds the hook URL from PUBLIC_API_URL, the studio id and the token, and tries to register it through the legacy trigger endpoint. That endpoint has been removed on SmartSend’s side, so the registration is best effort and its failure is ignored. In practice the webhook has to be configured on the SmartSend side.
Payload
parseInbound accepts one object or an array. Items without a string senderId and a string text are skipped.
InboundMessage: text, senderId, phone (the sender id with the @c.us or @g.us suffix removed), senderName, type (default chat), isGroup, isMyContact, timestamp.
What happens
recordInbound(studioId, payload):
- Group messages are skipped.
- The trainee is looked up with
clientByPhone(studioId, message.phone). - A
WhatsappMessagerow is created withdirection: 'INBOUND', the text asbodyand the parsed message aspayload.clientIdis set when a trainee matched. - When a trainee matched, an
InboxItemof typeMESSAGEis created withrefIdset to the message id andpriority: 1.
MessageStatus has DELIVERED and READ, but nothing writes them.
The agent webhook
The WhatsApp AI assistant is a single dedicated “Perform AI” number, a SmartSend organization owned by Perform, that serves every coach on the platform. A coach messages it, and it answers questions about their own studio.How SmartSend calls it
A SmartSend flow on the agent organization (trigger: any message, action: API request) posts each inbound message with this body:inboundBody in agent.routes.ts: phone and conversationId are required, text defaults to empty, senderName is optional.
Environment variables
Flow
Inbound, handleInbound
- A phone with fewer than 9 or more than 13 digits is ignored. A group or broadcast id is not a person.
- Outside the pilot list the number stays completely silent. No refusal message is sent.
- Empty text or a media marker such as
[image]gets a fixed “text only” reply. Media never reaches the model. - Otherwise the message is appended to a Redis list for that phone, the newest timestamp is stored, and a job is queued on
agent-reply(PerformQueue.AGENT_REPLY) with a delay ofDEBOUNCE_MS, 8 seconds.
Debounce
Every message schedules a job. When a job runs, it compares its timestamp with the stored latest one and walks away unless it matches. So a burst of messages produces one reply covering all of them. Pending messages are popped one at a time, not read and then deleted, so a message that lands in between is never lost.Reply
- The coach is resolved from the pinned map, then by phone. A coach can exist in several studios. The one they were last active in wins (
lastActiveAt). - An unknown number gets a “not linked” reply.
- A daily cap of 200 messages per phone (
DAILY_CAP) is enforced with a Redis counter. - History is kept in Redis: the last 24 entries (
HISTORY_KEEP) for 48 hours (HISTORY_TTL_SECONDS). - A fresh conversation, or a help word such as “help” or “menu”, gets the welcome menu first.
- The reply is split on blank lines into chunks of at most 3500 characters (
chunkReply) and sent withsendConversationMessage.
workers/agent-reply-worker.ts runs with a concurrency of 1.
How it reaches studio data
The assistant does not query the database. It uses the same tools as the MCP server and executes them the same way: a loopback HTTP call to/v1/automation on the same process, authenticated with a studio API key. That gives it the lane’s validation, audit logging and readable errors.
AGENT_TOOLSis every read-only MCP tool pluscreate_task. The other write tools (assign a plan, log a weight, send a form) are left out because they change a trainee’s world from a chat message.- The model runs through
generateTextfrom the Vercel AI SDK with@ai-sdk/anthropic, at most 8 steps (stopWhen: stepCountIs(8)) and 1200 output tokens. - A tool error is returned to the model as text so it can recover. The exception is a 401, which means the key was revoked. That is flagged and rethrown as
AgentKeyRevokedErrorafter the loop.
The studio key and the kill switch
The first time a coach’s studio is served,mintStudioKey creates a StudioApiKey named for the assistant and stores its plaintext in Studio.settings.agent.apiKey. The visible row in the coach’s integrations list is the per-studio kill switch.
When a loopback call comes back 401:
- The settings are rewritten to
agent: { revokedAt }, which drops the key. - The coach gets a message saying the connection was cancelled.
- The assistant does not mint a new key on its own. A revoked studio stays disconnected until the coach sends the reconnect command. Silently re-minting would turn the kill switch into a suggestion.
The assistant’s studio key is stored in plaintext in
Studio.settings, the same way the SmartSend organization key is. The key grants full automation API access to that studio.Related: the in-app coach assistant
/v1/web/assistant is a separate feature: a chat inside the coach web app (chat, confirm, thread). It also uses Anthropic through the AI SDK. It is listed in AI providers.