On this page
Agent Connect lets a business plug its own AI agent into Title. When a customer texts your business, Title passes the message to your agent. Your agent decides what to say and replies through the API, and Title delivers it as a rich RCS message.
Title is the phone line. Your agent decides what to say. Title never runs the AI, never writes the replies, and never speaks for your business. It handles the parts around the conversation: delivery, the shared inbox, opt-outs, quiet hours, rate limits and billing.
If you've used the Telegram Bot API, this will feel familiar: create a connection, get a key, receive messages by webhook (push) or polling (pull), and reply with one send call.
Set up a connection#
- In Title, open Settings > Agent Connect and click Connect an agent. (You need to be an org manager or brand manager.)
- Name the agent. Only your team sees this, for example in the inbox.
- Choose how your agent gets messages:
- Webhook URL: Title POSTs each event to your HTTPS endpoint as it happens. Best for agents hosted online.
- Pull: your agent asks Title for new events. Good for agents on a laptop or behind a firewall. No public URL needed.
- Choose who it can talk to: everyone who texts the number, only specific phone numbers (good for testing with your own phone), or contacts with certain tags.
- Set permissions:
- Let the agent start conversations (off by default). When off, the agent can only reply within 24 hours of a customer's message.
- Pause the agent when a teammate replies (on by default).
- Click Create. Title shows your API key and, in webhook mode, your signing secret. They are shown once. Store them in a password manager or your agent's environment variables. If you lose one, open the connection and use Replace key.
Every request to the API sends the key as a bearer token:
export TITLE_API_KEY="titlek_live_..."
curl https://titlebm.com/api/v1/agent/me \
-H "Authorization: Bearer $TITLE_API_KEY"
/me returns the connection the key belongs to:
{
"connection_id": "6f1c2a4e-...",
"name": "Support assistant",
"status": "active",
"delivery_mode": "pull",
"webhook_url": null,
"audience": { "type": "numbers", "numbers": ["+14125550100"], "tags": [] },
"permissions": { "allow_initiated": false, "auto_pause_on_human": true },
"last_acked_event_id": 0,
"brand": { "id": "a1b2c3d4-...", "name": "Your Business", "timezone": "America/New_York" },
"created_at": "2026-09-25T14:00:00.000Z"
}
The key only works for this one connection, and only on the /api/v1/agent/* endpoints (and the MCP endpoint, below). Every other Title API route answers 403 insufficient_scope for it.
Try it before going live. On the connection page, Send test event delivers a connection.test event to your agent, so you can check your webhook or polling loop without texting anyone.
Receiving messages#
Everything Title tells your agent is an event. Each event has the same envelope:
{
"version": "v1",
"event_id": "evt_msg_SM8f2e0c1d",
"event_type": "message.received",
"timestamp": "2026-09-25T14:03:11.402Z",
"brand_id": "a1b2c3d4-...",
"data": {
"connection_id": "6f1c2a4e-...",
"conversation_id": "0d3b7c9a-...",
"contact": {
"id": "5e8a1f20-...",
"phone": "+14125550100",
"first_name": "Sam",
"tags": ["vip"],
"consent": "opted_in"
},
"message": { "id": "9c41e7b2-...", "type": "text", "text": "Do you have anything open Saturday?", "button": null },
"channel": "rcs",
"source": "pinnacle",
"received_at": "2026-09-25T14:03:11.380Z"
}
}
event_id is stable: if Title delivers the same event twice, it has the same event_id. Use it to ignore duplicates, and as the Idempotency-Key when you reply.
Every event is stored for 7 days, whichever delivery mode you pick. A webhook connection can still pull, which is handy for catching up after an outage.
Pull: GET /updates#
GET /api/v1/agent/updates returns events after a cursor. If nothing is waiting, the request can stay open for up to wait seconds and returns as soon as something arrives (long polling).
| Parameter | Default | Meaning |
|---|---|---|
after | 0 | Return events with a cursor greater than this. Passing after=N also acknowledges everything up to N. |
limit | 50 | 1 to 100 events per call. |
wait | 0 | Seconds to wait when nothing is pending, up to 25. |
curl "https://titlebm.com/api/v1/agent/updates?after=0&wait=20" \
-H "Authorization: Bearer $TITLE_API_KEY"
{
"events": [
{ "cursor": 1042, "event": { "version": "v1", "event_id": "evt_msg_SM8f2e0c1d", "event_type": "message.received", "...": "..." } }
],
"next_cursor": 1042
}
Cursor rules
- Events come back oldest first. Each has a
cursor(a number that only goes up). - Save
next_cursorand pass it asafteron the next call. When there are no new events,next_cursorequals theafteryou sent. - Save the cursor only after you've handled the events, so a crash never skips a message.
- On a fresh start with no saved cursor,
last_acked_event_idfrom/meis where your agent last left off. - Events older than 7 days are deleted, so a cursor that old simply returns what is left.
A complete polling agent in Node 18 (no dependencies):
// agent.mjs: node agent.mjs
const BASE = 'https://titlebm.com/api/v1/agent';
const headers = { Authorization: `Bearer ${process.env.TITLE_API_KEY}`, 'Content-Type': 'application/json' };
let cursor = (await (await fetch(`${BASE}/me`, { headers })).json()).last_acked_event_id;
while (true) {
const res = await fetch(`${BASE}/updates?after=${cursor}&wait=20`, { headers });
if (!res.ok) { console.error(res.status, await res.text()); await new Promise((r) => setTimeout(r, 5000)); continue; }
const { events, next_cursor } = await res.json();
for (const { event } of events) {
if (event.event_type !== 'message.received') continue;
const { conversation_id, message } = event.data;
const reply = `You said: ${message.text}`; // ← your agent decides what to say
await fetch(`${BASE}/messages`, {
method: 'POST',
headers: { ...headers, 'Idempotency-Key': event.event_id },
body: JSON.stringify({ conversation_id, intent: { type: 'send_text', text: reply } }),
});
}
cursor = next_cursor;
}
The same loop in Python with requests:
# agent.py: pip install requests; python agent.py
import os, time, requests
BASE = "https://titlebm.com/api/v1/agent"
HEADERS = {"Authorization": f"Bearer {os.environ['TITLE_API_KEY']}"}
cursor = requests.get(f"{BASE}/me", headers=HEADERS, timeout=30).json()["last_acked_event_id"]
while True:
res = requests.get(f"{BASE}/updates", params={"after": cursor, "wait": 20}, headers=HEADERS, timeout=40)
if not res.ok:
print(res.status_code, res.text)
time.sleep(5)
continue
body = res.json()
for item in body["events"]:
event = item["event"]
if event["event_type"] != "message.received":
continue
data = event["data"]
reply = f"You said: {data['message']['text']}" # your agent decides what to say
requests.post(
f"{BASE}/messages",
headers={**HEADERS, "Idempotency-Key": event["event_id"]},
json={"conversation_id": data["conversation_id"], "intent": {"type": "send_text", "text": reply}},
timeout=30,
)
cursor = body["next_cursor"]
Webhook: Title POSTs to your URL#
In webhook mode, Title sends each event to your URL as a POST with the envelope above as the JSON body, plus these headers:
Content-Type: application/json
User-Agent: Title-Webhooks/1.0
X-Title-Event-Id: evt_msg_SM8f2e0c1d
X-Title-Event-Type: message.received
X-Title-Timestamp: 1790344991402
X-Title-Signature: sha256=5d41402abc4b2a76b9719d911017c592...
Respond with any 2xx status within 10 seconds. Do the slow work (calling your model, looking things up) after you respond, then reply through the send API. Title doesn't read your response body.
Retries
2xx: delivered.5xx,408,429, a timeout (10 seconds) or a network error: Title retries with exponential backoff, for up to 5 attempts in total (retries after roughly 2, 4, 8 and 16 minutes).- Any other
4xx: treated as a permanent failure and not retried. - Redirects are not followed. Your URL must use HTTPS.
- After 20 failed deliveries in a row, Title turns webhook delivery off for the connection. Events are still stored, so you can catch up with
/updates. To turn delivery back on, change the webhook URL on the connection (or switch it to Pull and back). - The connection page shows each delivery with its status code, and a Retry button for failed ones.
Because a retry can arrive after you already handled an event, skip any event_id you've already processed.
Verifying the signature#
Every webhook is signed with your connection's signing secret. The signature is an HMAC-SHA256 of the timestamp, a dot, and the raw request body:
X-Title-Signature = "sha256=" + hex( HMAC_SHA256( signing_secret, X-Title-Timestamp + "." + raw_body ) )
Compute it over the body exactly as received, before parsing the JSON, compare in constant time, and reject timestamps more than 5 minutes old so an old request can't be replayed.
Node 18 (Express):
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.TITLE_SIGNING_SECRET;
function isFromTitle(rawBody, timestamp, signature) {
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false; // replay protection
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps the body as bytes so the signature matches exactly.
app.post('/title', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!isFromTitle(rawBody, req.get('X-Title-Timestamp'), req.get('X-Title-Signature'))) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(rawBody);
res.sendStatus(200); // respond first (under 10 seconds)...
handleEvent(event).catch(console.error); // ...then do the work
});
app.listen(3000);
Python (Flask):
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["TITLE_SIGNING_SECRET"].encode()
def is_from_title(raw_body: bytes, timestamp: str, signature: str) -> bool:
if not timestamp or not signature or not timestamp.isdigit():
return False
if abs(time.time() * 1000 - int(timestamp)) > 5 * 60 * 1000: # replay protection
return False
expected = "sha256=" + hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
@app.post("/title")
def title_webhook():
raw = request.get_data() # raw bytes, before JSON parsing
if not is_from_title(raw, request.headers.get("X-Title-Timestamp", ""), request.headers.get("X-Title-Signature", "")):
abort(401)
event = request.get_json()
queue_for_processing(event) # respond within 10 seconds; do slow work elsewhere
return "", 200
Event reference#
Your agent receives these event types. Every data object includes connection_id.
message.received#
A customer sent your agent a message. data.message.type is text or button.
- Text:
message.textis what they typed;message.buttonisnull. - Button tap: the customer tapped a button or suggestion your agent sent.
message.textis the button label andmessage.buttonis{ "title", "payload" }, with thepayloadexactly as you sent it.
{
"event_type": "message.received",
"data": {
"connection_id": "6f1c2a4e-...",
"conversation_id": "0d3b7c9a-...",
"contact": { "id": "5e8a1f20-...", "phone": "+14125550100", "first_name": "Sam", "tags": [], "consent": "opted_in" },
"message": { "id": "9c41e7b2-...", "type": "button", "text": "Saturday 10am", "button": { "title": "Saturday 10am", "payload": "book_sat_10" } },
"channel": "rcs",
"source": "pinnacle",
"received_at": "2026-09-25T14:05:40.120Z"
}
}
| Field | Meaning |
|---|---|
conversation_id | Reply with this. |
contact | id, phone (E.164), first_name, tags, consent (opted_in, opted_out or unknown). |
message.id | The message's id in Title's inbox. |
channel | rcs or sms. |
source | The carrier connection it came through: pinnacle, twilio, or test. |
received_at | When Title received it. |
message.delivered / read / failed#
Delivery receipts for messages your agent sent. Receipts for messages sent by flows or by your team are not sent to the agent.
{
"event_type": "message.read",
"data": {
"connection_id": "6f1c2a4e-...",
"conversation_id": "0d3b7c9a-...",
"message": { "id": "71b0c2de-...", "provider_message_id": "msg_8f2e0c1d", "status": "read", "failure_reason": null },
"occurred_at": "2026-09-25T14:06:02.511Z"
}
}
message.id and message.provider_message_id match the message_id and provider_message_id you got back when you sent it. failure_reason is set on message.failed.
contact.opted_out / opted_in#
The customer texted STOP or the carrier reported an opt-out (contact.opted_out), or the customer texted START to opt back in (contact.opted_in). Title has already handled it and sent the required reply. After contact.opted_out, Title blocks your messages to that contact, so stop replying.
{
"event_type": "contact.opted_out",
"data": {
"connection_id": "6f1c2a4e-...",
"contact": { "id": "5e8a1f20-...", "phone": "+14125550100", "first_name": "Sam", "tags": [], "consent": "opted_out" },
"source": "keyword",
"occurred_at": "2026-09-25T14:10:00.000Z"
}
}
source says what caused it, for example keyword (the customer texted a keyword) or a carrier signal such as twilio_21610.
conversation.human_took_over / handed_back#
A person on the business's team took over the conversation, or handed it back to your agent.
{
"event_type": "conversation.human_took_over",
"data": {
"connection_id": "6f1c2a4e-...",
"conversation_id": "0d3b7c9a-...",
"source": "user",
"user_id": "b7e1...",
"reason": null,
"occurred_at": "2026-09-25T14:12:30.000Z"
}
}
source is user when a teammate did it from the inbox, or agent when your agent asked for a hand-off (then reason is the reason you gave). While a human has the conversation, your agent receives no messages from it and sends to it are rejected (409). After conversation.handed_back, carry on as normal.
connection.test#
Sent when someone clicks Send test event on the connection page. Safe to ignore; useful to check your endpoint.
{
"event_type": "connection.test",
"data": {
"connection_id": "6f1c2a4e-...",
"message": "Test event from Title. If you can read this, your connection works.",
"triggered_by": "b7e1...",
"occurred_at": "2026-09-25T14:00:05.000Z"
}
}
Sending messages#
POST /api/v1/agent/messages sends a message. Pass exactly one of:
conversation_id: reply in a conversation (from an event), orto: an E.164 phone number, to message a contact directly (see starting conversations below).
The message itself is an intent: you describe what to send, and Title builds the RCS message.
curl -X POST https://titlebm.com/api/v1/agent/messages \
-H "Authorization: Bearer $TITLE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: evt_msg_SM8f2e0c1d" \
-d '{
"conversation_id": "0d3b7c9a-...",
"intent": { "type": "send_text", "text": "Yes! We have 10am and 2pm open on Saturday." }
}'
const res = await fetch('https://titlebm.com/api/v1/agent/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TITLE_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': event.event_id,
},
body: JSON.stringify({
conversation_id: event.data.conversation_id,
intent: { type: 'send_text', text: 'Yes! We have 10am and 2pm open on Saturday.' },
}),
});
const result = await res.json();
if (!res.ok) console.error(res.status, result.error, result.message);
res = requests.post(
"https://titlebm.com/api/v1/agent/messages",
headers={"Authorization": f"Bearer {os.environ['TITLE_API_KEY']}", "Idempotency-Key": event["event_id"]},
json={
"conversation_id": event["data"]["conversation_id"],
"intent": {"type": "send_text", "text": "Yes! We have 10am and 2pm open on Saturday."},
},
timeout=30,
)
if not res.ok:
print(res.status_code, res.json()["error"], res.json()["message"])
A successful send returns 200:
{
"message_id": "71b0c2de-...",
"provider_message_id": "msg_8f2e0c1d",
"conversation_id": "0d3b7c9a-...",
"status": "sent",
"warnings": []
}
The message appears in the Title inbox with an Agent badge, so the team can see what your agent said.
Message types#
Text (up to 2,000 characters):
{ "type": "send_text", "text": "Thanks! Your order ships tomorrow." }
Text with suggestions. Suggestions are tappable chips under the message (up to 11, labels up to 25 characters). A reply suggestion comes back to your agent as a button tap with the postback as its payload (if you leave postback out, it defaults to the label in lowercase).
{
"type": "send_text",
"text": "Which day works best?",
"suggestions": [
{ "type": "reply", "label": "Saturday", "postback": "day_sat" },
{ "type": "reply", "label": "Sunday", "postback": "day_sun" },
{ "type": "url", "label": "See all times", "url": "https://example.com/book" },
{ "type": "dial", "label": "Call us", "phone": "+14125550199" }
]
}
Rich card with buttons (title up to 200 characters, description up to 2,000, up to 4 buttons):
{
"type": "send_rich_card",
"card": {
"title": "Deep tissue massage, 60 min",
"description": "Saturday at 10:00am with Jordan. $95.",
"media_url": "https://example.com/images/massage.jpg",
"buttons": [
{ "type": "reply", "label": "Book it", "postback": "book_sat_10" },
{ "type": "reply", "label": "Other times", "postback": "other_times" },
{ "type": "calendar", "label": "Add to calendar", "event_title": "Massage", "start_time": "2026-09-27T10:00:00-04:00", "end_time": "2026-09-27T11:00:00-04:00" }
]
}
}
Carousel (2 to 10 cards, same card shape):
{
"type": "send_carousel",
"cards": [
{ "title": "Classic", "description": "$40", "media_url": "https://example.com/classic.jpg", "buttons": [{ "type": "reply", "label": "Pick Classic", "postback": "classic" }] },
{ "title": "Deluxe", "description": "$65", "media_url": "https://example.com/deluxe.jpg", "buttons": [{ "type": "reply", "label": "Pick Deluxe", "postback": "deluxe" }] }
]
}
Image or video:
{ "type": "send_media", "media_url": "https://example.com/menu.jpg" }
Button and suggestion types: reply (comes back to your agent), url (opens a link), dial (calls a number), location (asks the customer to share their location; not available on every carrier connection), and calendar (adds an event). Unknown or misplaced fields are rejected with 400 invalid_intent, never silently dropped.
Button taps come back to you. Title marks the reply buttons your agent sends, so when the customer taps one, the tap goes to your agent (as message.received with type: "button") and not to any flow. Keep payloads short: payloads longer than 187 characters are cut.
Idempotency-Key#
Send an Idempotency-Key header (up to 255 characters) so a retry never sends the message twice. The event id you're replying to is a good key.
- Same key and same body within 24 hours: you get the first response back, with the header
Idempotent-Replayed: true, and nothing is sent again. - Same key with a different body:
422 idempotency_key_reused. - Same key while the first request is still running:
409 idempotency_in_progress. Retry shortly. - Errors worth retrying (
402,429and5xx) are not remembered, so you can retry with the same key once the cause is fixed.
Starting a conversation#
To message a contact who hasn't written recently, send to instead of conversation_id:
{ "to": "+14125550100", "intent": { "type": "send_text", "text": "Your weekly report is ready." } }
The contact must already exist on the brand; Agent Connect never creates contacts. If the contact hasn't messaged in the last 24 hours, this counts as agent-initiated, and it only goes through if:
- the connection has Let the agent start conversations turned on,
- the contact is opted in, and
- it's between 9:00am and 8:00pm in the contact's local time.
Errors#
Errors return JSON with an error code and a readable message, and sometimes details:
{
"error": "quiet_hours",
"message": "Agent-initiated messages can only be sent between 9:00 and 20:00 recipient local time (America/Chicago).",
"details": { "timezone": "America/Chicago" }
}
| Status | error | What happened |
|---|---|---|
| 400 | invalid_request | Bad JSON, missing intent, both or neither of conversation_id and to, or to isn't a phone number. |
| 400 | invalid_intent | The intent doesn't match a message type (details lists each problem). |
| 400 | invalid_idempotency_key | The Idempotency-Key is longer than 255 characters. |
| 402 | insufficient_credits | The brand is out of message credits. details has balance_cents and cost_cents. |
| 403 | opted_out | The contact opted out. Don't retry. |
| 404 | conversation_not_found | No such conversation for this connection (another brand's, or handled by another connection). |
| 404 | contact_not_found | No contact with that phone number on this brand. Agent Connect never creates contacts. |
| 409 | conversation_in_human_mode | A person has taken over. Wait for conversation.handed_back. |
| 409 | idempotency_in_progress | A request with the same Idempotency-Key is still running. |
| 422 | not_in_audience | The contact isn't in this connection's audience. |
| 422 | initiated_not_allowed | Agent-initiated message, but the connection can't start conversations. |
| 422 | consent_required | Agent-initiated message to a contact who isn't opted in. |
| 422 | quiet_hours | Agent-initiated message outside 9:00am to 8:00pm the contact's local time. details.timezone says which zone. |
| 422 | idempotency_key_reused | Same Idempotency-Key, different body. |
| 429 | rate_limited | Too many messages. details.scope is conversation or connection. Wait a minute. |
| 502 | send_failed | The carrier connection rejected the message. Safe to retry later. |
These can come back from any endpoint:
| Status | error | What happened |
|---|---|---|
| 401 | unauthorized | Missing key, wrong key, or a key that was replaced or revoked. |
| 403 | insufficient_scope | The key isn't an Agent Connect key. |
| 403 | agent_connect_disabled | Agent Connect isn't turned on for this brand. |
| 403 | connection_inactive | The connection is paused. /me and conversation history still work. |
Typing, hand-off and history#
Typing indicator#
Show a typing indicator to the customer while your agent works on a reply.
curl -X POST https://titlebm.com/api/v1/agent/typing \
-H "Authorization: Bearer $TITLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "conversation_id": "0d3b7c9a-..." }'
{ "supported": true, "sent": true }
Some carrier connections don't support typing indicators; then you get "supported": false and nothing is shown. It's best effort, so never wait on it. Errors: 404 conversation_not_found, 409 conversation_in_human_mode.
Hand off to a human#
When a person should take over (a refund, a complaint, anything your agent shouldn't handle), hand the conversation to the business's team:
curl -X POST https://titlebm.com/api/v1/agent/handoff \
-H "Authorization: Bearer $TITLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "conversation_id": "0d3b7c9a-...", "reason": "Customer is asking for a refund." }'
{ "conversation_id": "0d3b7c9a-...", "agent_state": "human", "escalation_id": "e3c1..." }
Title switches the conversation to human mode, tags the contact needs-attention, adds your reason as a note, and sends your agent conversation.human_took_over (with source: "agent"). Your agent gets no more messages from that conversation until someone clicks Hand back to agent in the inbox. Calling it again on a conversation that's already with a human returns "already_human": true.
It's polite to tell the customer first, for example "I'm passing you to someone on our team."
Conversation history#
Read the recent messages in one of your conversations, newest first:
curl "https://titlebm.com/api/v1/agent/conversations/0d3b7c9a-.../messages?limit=20" \
-H "Authorization: Bearer $TITLE_API_KEY"
{
"contact_id": "5e8a1f20-...",
"contact_name": "Sam Lee",
"contact_phone": "+14125550100",
"conversation_id": "0d3b7c9a-...",
"agent_state": "agent",
"messages": {
"items": [
{ "id": "9c41e7b2-...", "direction": "inbound", "kind": "text", "body": "Do you have anything open Saturday?", "media": null, "provider_message_id": "SM8f2e0c1d", "sent_at": "2026-09-25T14:03:11.380Z", "delivered_at": null, "read_at": null, "created_at": "2026-09-25T14:03:11.402Z" }
],
"total": 12,
"limit": 20,
"offset": 0
}
}
limit is 1 to 100 (default 50). The history includes everything in the conversation, including messages from your team and from flows. agent_state is agent, human or none.
Rules Title enforces#
Title applies these rules to every agent message, so your agent can't break them by mistake:
- Opt-outs. STOP, START and HELP are handled by Title and never reach your agent. After a contact opts out, every message to them is rejected (
403 opted_out) and you getcontact.opted_out. - Starting conversations. A message to someone who hasn't written in the last 24 hours needs the "start conversations" permission, an opted-in contact, and the contact's local time between 9:00am and 8:00pm. The time zone comes from the phone number's area code, or the brand's time zone if it can't be told.
- Rate limits. At most 10 agent messages per conversation per 60 seconds, and 60 per connection per minute (
429 rate_limited). - Human takeover. When a teammate replies from the inbox (with the auto-pause setting on) or clicks Take over, the agent is paused for that conversation: its messages don't reach your agent and your sends get
409until it's handed back. - Keyword flows win exact matches. If a customer's text exactly matches a keyword flow the business published (for example "MENU"), the flow answers and your agent doesn't get that message. Taps on buttons sent by flows also go to the flow; only taps on your agent's own buttons come to you.
- Audience. Messages from contacts outside the connection's audience stay in the inbox and never reach your agent, and you can't message them (
422 not_in_audience). - Paused connections. While a connection is paused, new messages are not queued for it; they stay in the inbox for the team.
- Contacts. Agent Connect never creates contacts. Customers who text the business are added automatically.
- Credits. Each agent message uses the brand's message credits, at the same rate as a message sent from the inbox. When credits run out, sends return
402 insufficient_credits. - Your content, your responsibility. Title delivers what your agent writes. Laws about disclosing that a customer is talking to an AI (for example in California and Utah) apply to the business.
Use it from an MCP client#
Title's MCP server accepts your Agent Connect key too. Connected with the key, an MCP client (Claude, or any client that supports remote MCP servers over HTTP) gets only the agent tools, scoped to your one connection:
| Tool | Same as |
|---|---|
agent_me | GET /me |
agent_get_updates | GET /updates (after, limit, wait up to 20 seconds) |
agent_reply | POST /messages (conversation_id or to, intent, optional idempotency_key) |
agent_send_typing | POST /typing |
agent_handoff | POST /handoff |
agent_get_conversation | GET /conversations/{id}/messages |
The same rules and error codes apply; errors come back as tool errors like Error 409 conversation_in_human_mode: ....
Server URL: https://titlebm.com/api/mcp, with the header Authorization: Bearer titlek_live_....
Claude Code:
claude mcp add --transport http title-agent https://titlebm.com/api/mcp \
--header "Authorization: Bearer $TITLE_API_KEY"
Most other clients take a JSON config like this:
{
"mcpServers": {
"title-agent": {
"type": "http",
"url": "https://titlebm.com/api/mcp",
"headers": { "Authorization": "Bearer titlek_live_..." }
}
}
}
Check it with curl (lists the six tools, then fetches updates):
curl https://titlebm.com/api/mcp \
-H "Authorization: Bearer $TITLE_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
curl https://titlebm.com/api/mcp \
-H "Authorization: Bearer $TITLE_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"agent_get_updates","arguments":{"after":0,"wait":0}}}'
The server tells the model how to use the tools: poll agent_get_updates with the last next_cursor, reply with agent_reply using the event id as the idempotency key, stop replying when a human takes over, and never message someone who opted out.
Endpoint reference#
Base URL: https://titlebm.com/api/v1/agent. Every request needs Authorization: Bearer <your key>.
| Method | Path | What it does |
|---|---|---|
GET | /me | The connection this key belongs to. |
GET | /updates?after=&limit=&wait= | Events after a cursor (pull). |
POST | /messages | Send a message: { conversation_id or to, intent }, optional Idempotency-Key header. |
POST | /typing | Typing indicator: { conversation_id }. |
POST | /handoff | Hand to a human: { conversation_id, reason? }. |
GET | /conversations/{id}/messages?limit= | Recent messages in a conversation. |