Guides

Build an AI agent people can text on iMessage

This guide builds an iMessage AI agent: a small Python service people text from the Messages app. A person texts your join code once. After that, each message they send reaches your webhook, your model writes the answer, and one API call puts it back in their thread as a blue bubble.

It runs on Blueping, an independent iMessage API for developers that is not affiliated with Apple (Apple has no public iMessage API). The model call is a placeholder, ask_model(history), so any model you already use fits.

Why iMessage works for an AI assistant

Most assistants ask for something before they help, usually an app to install or an account to create. An AI assistant on iMessage asks for one text. After that, the conversation lives in an app people already open all day, a few threads below their family group chat.

Messages handles notifications and keeps the thread on their phone, so you build neither. Expect short questions typed with one thumb, and threads picked back up days later.

The trade-off is reach: iMessage runs on Apple devices only, so your agent talks to people with an iPhone, iPad or Mac and nobody else.

How the pieces fit together

The agent is one webhook endpoint plus a few JSON calls to https://blueping.pages.dev/v1, authenticated with Authorization: Bearer bp_live_.... One message travels like this:

  • You share your join link, https://blueping.pages.dev/j/CODE. A person texts your code to the Blueping line once, and your webhook receives contact.joined.
  • Each text they send after that arrives as a signed message.received event.
  • Your handler verifies the signature, loads the last few turns and calls your model.
  • It sends the reply with POST /v1/messages to the contact id (ct_...) from the event.
{
  "id": "evt_...",
  "type": "message.received",
  "created_at": "2026-11-04T18:02:11.000Z",
  "data": {
    "message": {
      "id": "msg_...",
      "from": { "id": "ct_...", "handle": "+15551234567" },
      "text": "what can I use instead of buttermilk?",
      "created_at": "2026-11-04T18:02:11.000Z"
    }
  }
}

To get a key, open https://blueping.pages.dev/start and text the 6-character code it shows to the Blueping line from your iPhone. The page displays your API key, and your iPhone becomes the contact me. Then set your webhook and app name:

# Tell Blueping where to post events. Save the returned secret as BLUEPING_WEBHOOK_SECRET.
curl -X PUT https://blueping.pages.dev/v1/webhook \
  -H "Authorization: Bearer $BLUEPING_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://agent.example.com/blueping"}'

# Set the app name people see (1 to 40 characters).
curl -X PATCH https://blueping.pages.dev/v1/me \
  -H "Authorization: Bearer $BLUEPING_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Chervil, an AI cooking assistant"}'

# Get your join code, join link, sms_url and the line people text.
curl -X POST https://blueping.pages.dev/v1/invites \
  -H "Authorization: Bearer $BLUEPING_API_KEY"

Store the whsec_... secret that PUT /v1/webhook returns as BLUEPING_WEBHOOK_SECRET. Since your iPhone is already a contact, you can test the whole loop by texting the line yourself.

The full webhook handler in Python

Here is the whole agent in one file, with Flask for the endpoint and requests for the API. It runs as is: ask_model() echoes the last message, so you can watch the plumbing work before you spend a token.

# agent.py: an AI agent people can text on iMessage, built on Blueping.
# pip install flask requests
# export BLUEPING_API_KEY=bp_live_... BLUEPING_WEBHOOK_SECRET=whsec_...
# flask --app agent run --port 8000

import hashlib
import hmac
import json
import os
import re
import threading
import time
from collections import deque

import requests
from flask import Flask, abort, request

API = "https://blueping.pages.dev/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['BLUEPING_API_KEY']}"}
WEBHOOK_SECRET = os.environ["BLUEPING_WEBHOOK_SECRET"].encode()

MAX_TURNS = 12   # about six exchanges, enough for follow-up questions
MAX_CHARS = 600  # one phone screen; the API accepts up to 2,000
SYSTEM_PROMPT = (
    "You are Chervil, an AI cooking assistant that people text on iMessage. "
    "Write plain text only: no Markdown, headings, tables or bullet lists. "
    "Answer in a single message and keep most replies under 300 characters. "
    "If someone asks whether you are a person or a bot, say you are an automated AI assistant. "
    "If someone asks you to stop messaging them for good, reply with exactly OPT_OUT."
)
WELCOME = "Hi, I'm Chervil, an AI cooking assistant. Ask me anything about what's on your stove. Text STOP to unsubscribe."
FALLBACK = "Sorry, I lost my train of thought. Could you ask that again?"
# Only messages that are nothing but a refusal. The model catches the other wordings.
REFUSAL = re.compile(
    r"\W*(please\W+)?(leave me alone|(stop|quit) (texting|messaging|contacting) me"
    r"|don[’']?t (text|message|contact) me (again|anymore))\W*",
    re.IGNORECASE,
)

app = Flask(__name__)
lock = threading.Lock()
memory = {}                # contact id -> deque of {"role", "content"}, oldest first
seen = deque(maxlen=1000)  # recent event ids, so a repeated delivery is ignored


def ask_model(history):
    """Placeholder for your model: send SYSTEM_PROMPT and history, return the reply text.
    history is a list of {"role": "user" or "assistant", "content": "..."}, oldest first.
    This version echoes, so you can test the plumbing before you wire in a model."""
    return "You said: " + history[-1]["content"]


def signature_is_valid(raw_body, header, tolerance=300):
    fields = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part)
    timestamp, received = fields.get("t", ""), fields.get("v1", "")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
        return False  # missing, or signed more than five minutes ago
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(WEBHOOK_SECRET, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), received.encode())


def for_phone(text):
    text = re.sub(r"\[([^\]]+)\]\((\S+?)\)", r"\1 (\2)", text)  # [label](url) becomes label (url)
    text = re.sub(r"\*\*|__|`", "", text)                       # bold and code markers
    text = re.sub(r"^#+\s*", "", text, flags=re.MULTILINE)       # heading marks
    text = text.strip()
    return text if len(text) <= MAX_CHARS else text[: MAX_CHARS - 3].rstrip() + "..."


def send(contact_id, text, key):
    resp = requests.post(
        f"{API}/messages",
        headers={**HEADERS, "Idempotency-Key": key},  # the same event never texts twice
        json={"to": contact_id, "text": text},
        timeout=15,
    )
    if resp.status_code == 202:
        return True
    app.logger.warning("Not sent to %s: %s", contact_id, resp.text)  # awaiting_reply, daily_limit_reached...
    return False


def forget(contact_id):
    with lock:
        memory.pop(contact_id, None)


def opt_out(contact_id):
    # A refusal written in free text: unsubscribes the person from every app on the line.
    requests.post(f"{API}/contacts/{contact_id}/opt-out", headers=HEADERS, timeout=15).raise_for_status()
    forget(contact_id)


def recent_turns(contact_id, current_id):
    with lock:
        if contact_id in memory:
            return memory[contact_id]
    # Nothing in memory (a first message, or a restart): rebuild from Blueping's log.
    turns = deque(maxlen=MAX_TURNS)
    resp = requests.get(f"{API}/messages", params={"limit": 50}, headers=HEADERS, timeout=15)
    if resp.ok:
        for m in resp.json()["data"]:  # your latest messages, every contact, oldest first
            if m["id"] == current_id:
                continue  # the message being answered is added by the caller
            if m["contact"]["id"] == contact_id:
                role = "user" if m["direction"] == "in" else "assistant"
                turns.append({"role": role, "content": m["text"]})
    with lock:
        return memory.setdefault(contact_id, turns)


def answer(event):
    message = event["data"]["message"]
    contact_id, text = message["from"]["id"], message["text"]
    if REFUSAL.fullmatch(text):
        opt_out(contact_id)
        return

    turns = recent_turns(contact_id, message["id"])
    with lock:
        turns.append({"role": "user", "content": text})
        history = list(turns)

    reply = ask_model(history)
    if reply.strip().startswith("OPT_OUT"):
        opt_out(contact_id)
        return

    reply = for_phone(reply) or FALLBACK
    if send(contact_id, reply, key=event["id"]):
        with lock:
            turns.append({"role": "assistant", "content": reply})


def in_background(target, *args):
    threading.Thread(target=target, args=args, daemon=True).start()


@app.post("/blueping")
def webhook():
    raw = request.get_data()
    if not signature_is_valid(raw, request.headers.get("Blueping-Signature", "")):
        abort(401)
    event = json.loads(raw)
    with lock:
        if event["id"] in seen:
            return "", 200
        seen.append(event["id"])

    kind, data = event["type"], event["data"]
    if kind == "message.received":
        in_background(answer, event)  # answer 2xx within 10 s; the model may take longer
    elif kind == "contact.joined":
        in_background(send, data["contact"]["id"], WELCOME, event["id"])
    elif kind in ("contact.left", "contact.opted_out"):
        forget(data["contact"]["id"])
    return "", 200

Blueping posts to an https:// URL: deploy the app, or use an HTTPS tunnel while you test. Three details matter more than their line count:

  • It acknowledges first and thinks second. Blueping expects a 2xx within 10 seconds and retries failed deliveries after 30 s, 2 min, 10 min, 1 h and 6 h, so the handler returns 200 at once and calls the model in a background thread (use a job queue in production).
  • A retry can't text someone twice. The event id doubles as the Idempotency-Key, so a repeated event gets the first answer back, with idempotent-replayed: true, instead of sending a second message. Blueping keeps idempotency keys for 24 hours.
  • It checks the signature on the raw body before parsing JSON. The handler recomputes the HMAC-SHA256 of <t>.<raw body> with your secret, compares it in constant time with hmac.compare_digest, and drops deliveries signed more than five minutes ago.

Keep a short history for each contact

The model needs the last few turns, not the whole thread, to make sense of a follow-up like "and for dessert?" Twelve messages, about six exchanges, cover most follow-ups and keep each model call small and fast.

The handler keeps those turns in memory, keyed by contact id. On a contact's first message or after a restart, recent_turns() rebuilds their history from GET /v1/messages, which returns your latest messages, oldest first: in messages become user turns and out messages become assistant turns. To fetch only what is new since a message you know, pass its id as after.

Those latest messages cover all your contacts, and the in-memory store empties on every deploy, so a real agent should keep each turn in its own database. When someone leaves or opts out, delete what you kept, as the handler does on contact.left and contact.opted_out.

Write for a phone screen

Models trained on chat interfaces reach for Markdown, and Messages does not render it: Tip: arrives with its asterisks, and a ### heading looks like a typo. The system prompt asks for plain text, and for_phone() catches what slips through by stripping bold, code and heading marks, rewriting links as label (url) and cutting anything past 600 characters.

An answer that fits a laptop window is a wall of text on a phone. Ask for the answer first, in a few sentences, and let the person ask for more. When someone texts "what can I use instead of buttermilk?" the right reply is one line: "Mix 1 cup of milk with 1 tablespoon of lemon juice and let it sit for 5 minutes." The heading, the three options and the paragraph of food science can stay in the model.

The API accepts up to 2,000 characters per message; the handler caps replies at 600, and most should be far shorter.

One good reply beats three

An app can send at most 5 messages in a row before the person replies, and their reply resets the count. A normal back-and-forth never gets close. An agent that splits every answer into bubbles, or keeps following up, gets there fast.

Each extra bubble counts toward the 5 in a row, leaving fewer messages for the reminder you meant to send later. A three-bubble answer also spends three of your 100 messages a day where one would do. And since there is no typing indicator, the person can't tell more is coming and may answer the first bubble before the rest land.

Before a message the person didn't prompt, check remaining_before_reply in GET /v1/contacts. At zero, the API answers awaiting_reply (429, remaining: 0), and the only fix is to wait for a reply.

Honor STOP and refusals written in plain words

Blueping answers STOP, HELP, MENU and LEAVE itself. STOP unsubscribes the person from every app on the line, and your webhook receives contact.opted_out. From then on, sends to them return contact_opted_out (409) until they text a code again, which counts as a fresh opt-in.

People don't always use the keyword. "Please leave me alone" and "don't text me again" are refusals too, and a cheerful follow-up question is a quick way to lose someone's trust. Report a refusal written in free text with POST /v1/contacts/{id}/opt-out. Like STOP, it unsubscribes the person from every app.

The handler catches refusals two ways. A strict pattern matches messages that are nothing but a refusal, so "how do I get my cat to leave me alone?" stays a question. For other wordings, the system prompt tells the model to answer exactly OPT_OUT. Either way, the handler calls the opt-out endpoint, sends nothing more and deletes the history.

Say the replies are automated when asked

When someone asks whether they are talking to a person, the agent should say the replies are automated, every time. The rule sits in the system prompt; test it before you share your link by texting "is this a real person?" from your own phone.

A persona is fine, but a fake human is not, so give the agent a name rather than a backstory with a commute. The app name people see comes from PATCH /v1/me (1 to 40 characters), and "Chervil, an AI cooking assistant" answers the question before anyone asks. The welcome the handler sends on contact.joined does the same.

Limits to design around

The free plan and API v1 come with these limits:

  • API v1 is text only, with no attachments yet, so send a link when the answer is a file.
  • People need an iPhone, iPad or Mac. There is no SMS fallback and no Android.
  • There are no typing indicators, read receipts or tapbacks. People see nothing while your model works, so keep it fast. For a slow task, one holding message ("Checking, one minute") is worth what it costs.
  • Each account gets 100 messages a day, reset at 00:00 UTC. Past that, sends return daily_limit_reached (429) with a resets_at time.
  • The free line is shared. When it reaches its daily volume, sends return line_busy (429). A line_error (502) means one send failed; retry it later.
  • A person talks to one app at a time. Texting another app's code switches them to it, and you receive contact.left with the reason switched; texting your code brings them back.

Start with your own phone

Open https://blueping.pages.dev/start, text the code from your iPhone and copy your key. You don't need a password or a credit card, and there is no sales call. When the echo placeholder answers your first text, swap in your model and share your join link.

Questions

Is there an official iMessage API for chatbots and AI agents?

No. Apple has no public iMessage API. Blueping is an independent service, not affiliated with Apple, that gives your code a JSON API and webhooks for iMessage.

Which AI model can I use?

Any model your server can call. Blueping delivers the person's text and sends the text you return, so ask_model(history) is the only function to replace.

Can Android users text my AI agent?

No. Blueping is iMessage only, so people need an iPhone, iPad or Mac. There is no SMS fallback.

Can my agent text someone first?

Only your own iPhone (me) and people who texted your join code. A send to anyone else returns contact_not_joined (409) with your join_url.

Can the agent send images or voice notes?

Not yet. API v1 is text only, up to 2,000 characters per message. Send a link when the answer is a file.

How much does an iMessage AI agent cost on Blueping?

The free plan is $0: 100 messages a day per account on the shared line, with at most 5 in a row before a person replies. A dedicated number is coming soon, with a waitlist.