Skip to main content
connic
Back to BlogTutorial

Build a WhatsApp Support Agent with Twilio and connic

Connect WhatsApp to a connic agent through Twilio: keep each customer's history, route replies to the right number, and handle templates and failed sends.

November 10, 20269 min readAuthor: connic engineering

A WhatsApp support agent on connic is a deployed agent with two Twilio Messaging connectors: an inbound connector that starts a run for every message a customer sends, and an outbound connector that sends the agent's answer back. This tutorial sets up both, keeps each customer's conversation in a persistent session, makes sure every reply reaches the person who wrote, and covers the cases where WhatsApp requires a template or Twilio doesn't take a message.

Prepare the sender

connic sends and receives WhatsApp messages through Twilio's US1 region. IE1 handles SMS text only, and AU1 doesn't support Messaging. Under Project Settings → Connections, create a Twilio Connection with region US1, the Account SID (it starts with AC), and the Auth Token of that region. The connector files below refer to it by its name, support-twilio, so the credentials never appear in code. The same Connection can also serve Twilio Voice connectors for phone calls.

For a first test, Twilio's WhatsApp Sandbox replaces a registered sender. Once the Sandbox is activated in Twilio, the test phone joins it by sending the join code to the Sandbox number, whatsapp:+14155238886. The Sandbox session expires three days after joining, and the phone has to join again. In production, the sender is a registered WhatsApp sender in the same Twilio account, and switching to it means changing sender in both connector files.

Telnyx Messaging connects WhatsApp the same way, with separate inbound and outbound connectors. This tutorial uses Twilio.

Connect an inbound and an outbound connector

The inbound connector receives messages and starts a run of each linked agent, here whatsapp-support. It answers Twilio with an empty response and never sends anything itself. Replies leave through a separate outbound connector. Both are declared in connectors/*.yaml and deploy with the agent. Phone numbers need quotes, because YAML reads a bare +14155238886 as an integer and the deploy fails:

connectors/support-whatsapp.yaml
type: twilio_messaging
mode: inbound
connection: support-twilio
config:
  channel: whatsapp
  sender: "whatsapp:+14155238886"   # Twilio's WhatsApp Sandbox
agents:
  - whatsapp-support

The outbound connector uses the same Connection, channel, and sender:

connectors/support-whatsapp-replies.yaml
type: twilio_messaging
mode: outbound
connection: support-twilio
config:
  channel: whatsapp
  sender: "whatsapp:+14155238886"
agents:
  - name: whatsapp-support
    outbound: { usage: automatic, source: [support-whatsapp] }

usage: automatic sends the agent's final output when its run completes, and source limits that to runs the inbound file started. Without source, the connector would also send after runs from other triggers, which have no WhatsApp customer to answer. In the dashboard, the same setup is Automatic outbound connector with Only selected inputs and the inbound connector selected.

On a registered sender, connic sets the incoming-message webhook in Twilio when it creates the inbound connector. It replaces the URL that was there and writes the previous value to the connector's logs and the project's audit log. If a Messaging Service contains the sender, the service has to be set to Defer to sender's webhook first. Twilio offers no documented API for the Sandbox webhook, so that step is manual: copy the Incoming-message webhook from the connector's details drawer, paste it into the Sandbox settings under When a Message Comes in, select HTTP POST, and save.

What a run receives

Each incoming message starts one run per linked agent with this input, shortened here:

input.json
{
  "message_sid": "SM00000000000000000000000000000001",
  "channel": "whatsapp",
  "from": "whatsapp:+14155550124",
  "to": "whatsapp:+14155238886",
  "text": "Can I still return the blender I bought three weeks ago?",
  "conversation_id": "twilio_messaging:9e0f77d9-2fd8-5a8e-8d47-59fbf5b8a091",
  "raw": {
    "MessageSid": "SM00000000000000000000000000000001",
    "From": "whatsapp:+14155550124",
    "To": "whatsapp:+14155238886",
    "NumMedia": "0"
  }
}

from is the customer and to the sender, both with the whatsapp: prefix. message_sid is Twilio's ID for the message; if Twilio sends the same message twice, no second run starts. raw holds every field Twilio sent.

Customers often send a photo instead of a description. connic downloads incoming media into files, up to 50 files and 32 MiB per message, and lists every file it had to leave out in file_issues, which the agent should check as well. The model has to understand what customers send: connic/gemini-3.8-flash understands photos, PDFs and voice notes, so the agent answers a voice note like a typed message.

Keep each customer's conversation

Every message starts a separate run, and without a session the agent sees only the message in front of it. conversation_id stays the same for each combination of inbound connector, Twilio account, region, channel, sender, and customer, so it identifies one customer's conversation with one number. As the key of a persistent session, it gives every customer their own history:

agents/whatsapp-support.yaml
version: "1.0"

name: whatsapp-support
type: llm
model: connic/gemini-3.8-flash
description: "Answers customer questions on WhatsApp"
system_prompt: |
  You answer customer questions for the Acme store on WhatsApp.
  Returns: within 30 days of delivery, started under Orders in the
  customer's account; the return label arrives by email.
  Keep replies short, answer in the customer's language,
  and return the reply as plain text.

max_concurrent_runs: 10

session:
  key: input.conversation_id
  ttl: 604800   # history expires after 7 days without a message

concurrency:
  key: conversation_id
  on_conflict: queue

Sessions are off until the agent file sets session, and the change takes effect with the next deploy. ttl ends a history after seven days without a message, so the customer's next message starts fresh. Without ttl, history never expires. The same two messages, with and without the session key:

With session.key
  1. Customer: Can I still return the blender I bought three weeks ago?

  2. Agent: Yes, returns are possible within 30 days of delivery. Do you want to know how?

  3. Two hours later
  4. Customer: Yes, please

  5. Agent: Open Orders in your account, choose the blender, and select Return. The return label arrives by email.

Without session.key
  1. Customer: Can I still return the blender I bought three weeks ago?

  2. Agent: Yes, returns are possible within 30 days of delivery. Do you want to know how?

  3. Two hours later
  4. Customer: Yes, please

  5. Agent: Happy to help. What would you like to do?

Without the session, the second run has no idea what the customer agreed to. Photos in a long conversation would reach the model again with every message; files: false in the session block replaces earlier files with a short note and still passes the files of the current message.

Messages sent in quick succession

WhatsApp users often split one thought across several messages. Each one starts a run seconds after the last. Runs that share a session don't wait for each other: when two overlap, the run that writes its history second fails, and that message goes unanswered. The concurrency block prevents this. With key: conversation_id and on_conflict: queue, the run for a second message from the same customer waits until the first run has finished, then starts with that exchange in its history. Each message still gets its own reply, and the runs handle the messages in the order they arrived. on_conflict: coalesce doesn't fit here because it cancels a waiting run when a newer message arrives, so the cancelled message never reaches the history.

The concurrency rule works per conversation. max_concurrent_runs limits the agent as a whole and defaults to 1, which would make every customer wait for every other one. The example raises it to 10, which needs the developer plan or higher. Basic allows 1, and a deploy above the plan's cap fails. Runs that wait for their conversation don't take one of those slots.

Send the reply to the right number

The outbound connector needs a recipient for every message and takes the first one it finds:

  1. to in the agent's output, when the output is a JSON object.
  2. The outbound connector's Default recipient, to in its config.
  3. The customer who wrote, for a run that an inbound Twilio Messaging connector started. Both connectors must use the same Connection, channel, and sender.

The third rule covers the normal case, which is why the example sets no Default recipient. A Default recipient comes first, so one left over from testing, such as a team member's phone, receives every customer's reply.

The third rule also requires an exact match. If the outbound connector uses a different sender, for example because only the inbound file was switched from the Sandbox to the registered sender, the reply has no recipient, and the connector run fails with "Set a recipient, provide 'to' in the output, or reply through the same Twilio connection and sender".

Message customers outside the 24-hour window

WhatsApp allows free-form messages only within 24 hours of the customer's last message. After that, a business can only send a template that WhatsApp has approved, such as a shipping notice with placeholders for the order number and the delivery day. The template is created and submitted for approval in Twilio, which gives it a Content SID starting with HX; Twilio's template guide covers the steps. An outbound message names the template and fills its variables:

template-payload.json
{
  "to": "whatsapp:+14155550124",
  "content_sid": "HX00000000000000000000000000000000",
  "content_variables": { "1": "12345", "2": "Friday" }
}

Since the customer hasn't written for a day, a message like this usually isn't a reply and sets to itself. The agent passes it as its JSON output on an automatic link, or as the payload when it calls the connector as a tool. Variable values must be strings, and a message with content_sid can't also carry text or media_urls. Custom templates need a registered sender; the Sandbox only offers Twilio's pre-approved test templates.

The connector can set content_sid and content_variables as defaults, which the payload overrides. On the reply connector, a default template turns every reply into that template. Plain text from the agent is then ignored, and media URLs are rejected. A second outbound connector for template messages leaves the reply connector free for normal answers.

When a message doesn't go out

Every send is a run of the outbound connector. A completed run means Twilio accepted the message, not that it reached the phone. Its output holds the Message SID and the status Twilio returned. Twilio's Messaging Logs show the delivery status and later channel errors for that SID. A failed run falls into one of these cases:

CaseWhat connic doesNext step
connic can't connect to Twilio, or Twilio answers 429Tries up to 3 times in total, after a 429 waiting as long as Twilio's Retry-After asks, up to five minutesNone, unless the last attempt fails too
Twilio rejects the messageFails the run with the HTTP status and Twilio's error code, without a retryCheck the sender, recipient, and template in Twilio
The outcome is unknown: the connection fails during the request, or Twilio answers with a 5xx statusFails the run without a retry, because Twilio may already have accepted the messageLook for the message in Twilio's Messaging Logs before resending
The message is invalid: no recipient, text over 1,600 characters, more than one media URL, or a content_sid in the output together with textFails the run before contacting TwilioFix the agent's output or the connector

The unknown case has no automatic retry because Twilio documents no way to recognize a repeated send request, so a retry could reach the customer twice. If the agent's own run fails, nothing is sent and the customer gets no answer; the run's trace shows why.

WhatsApp also limits media. A message takes one media URL, and WhatsApp drops text sent along with audio, video, documents, or other non-image media. Text that belongs with a PDF has to go out as a separate message, for example through a second outbound connector that the agent calls as a tool. The Twilio Messaging reference lists every field, limit, and status code.

Answer your customers on WhatsApp

Deploy an agent on connic, connect your Twilio sender, and answer every customer with their earlier messages in context.

Frequently Asked Questions

Yes. Twilio's WhatsApp Sandbox works as the sender. Create both connectors with the Sandbox number, whatsapp:+14155238886, and paste the inbound connector's webhook URL into the Sandbox settings by hand, because connic can't set it there. The test phone has to join the Sandbox first.

Only with a persistent session. Each message starts its own run. Setting session.key to input.conversation_id gives each customer one history per sender, and a ttl ends that history after a period without messages. A concurrency rule on conversation_id keeps messages sent in quick succession from overlapping.

A Default recipient on the outbound connector takes precedence over replying to the customer who wrote. Leave it empty. Replies then go to the customer who wrote, as long as the inbound and outbound connectors use the same Connection, channel, and sender; otherwise the send fails without a recipient.

No. It means Twilio accepted the message. The run's output holds the Message SID, and Twilio's Messaging Logs show the delivery status and any later channel errors for it.

Twilio gets an empty response and no run starts. connic doesn't store or replay those messages, so they stay unanswered after the connector resumes.

More from the Blog

Tutorial

Track AI Token Costs by Customer or Use Case in connic

Middleware tags each connic agent run with a customer or use case, so dashboards and the Token Usage page can rank token cost by that value.

October 15, 20267 min read
Tutorial

Expose the Invoice Processor Template as an MCP Tool

Deploy connic's invoice template as an MCP tool, discover it from Python, send invoice text, and check the extracted fields with a complete client example.

September 11, 202610 min read
Tutorial

How to Deploy a Python AI Agent Without Kubernetes

A Python AI agent deployed without Kubernetes using YAML, plain Python, deployment-gated tests, Git, and a managed EU runtime. Includes working code.

August 12, 202612 min read
Tutorial

How Small Engineering Teams Can Add an AI Agent to SaaS

A practical, step-by-step path to shipping a first production AI agent with a small team: scope one job, define it in config, connect it to existing systems, and let a runtime handle the rest.

June 12, 20269 min read
Tutorial

Automated Agent Scoring: AI Agent Evaluation with LLM Judges

Automated agent scoring uses an LLM judge to grade sampled or every matching agent run against defined criteria. Track score trends and alert on regressions.

March 29, 202610 min read
Tutorial

Database vs Retrieval vs Sessions: Agent Memory Compared

A comparison of connic's Database, Retrieval, and persistent sessions, including identity, TTL, sanitized events, and runs matched to that identity.

March 4, 202612 min read
Tutorial

AI Agents: From Prototype to Production

A demo works great until 1,000 concurrent users arrive. A practical guide to the production requirements most teams find out about too late.

January 10, 202610 min read
Tutorial

Hidden Costs of Self-Hosting AI Agents

We'll just deploy it on Kubernetes. Famous last words. The true cost of self-hosting AI agents versus a managed platform.

December 18, 20257 min read
Tutorial

connic RAG: Ranked Retrieval and Links to Source Files

Build a production RAG agent in connic that searches by meaning and exact terms, ranks passages by relevance, cites its sources, and links the original files.

November 15, 202512 min read