Guides

Building iMessage Agents: The Complete Guide

Short answer: an iMessage agent needs three things: an iMessage number, an AI agent with instructions and tools that call your APIs, and something that runs the conversation. With ZeroSettle you get a dedicated number on real iMessage, then either switch on the hosted agent and write what it should know (no server), or point the number's signed webhook at your own model. The hosted route takes minutes; the TypeScript agent in this guide is about 150 lines.

Key takeaways

  • Two ways to build: the hosted agent (Managed by AI Agent) answers from a business context, files and MCP servers you connect; or your own code receives every text on a webhook and answers with your model and keys (Managed by API).
  • The thread is the memory. Every message both ways is stored on the line, so GET /api/imessage/messages?handle=… is the conversation history. You keep no transcript of your own.
  • The phone number is the user. Scope every lookup to the number that texted, never only to what the model passes in.
  • Native, not just text: typing bubbles, tapbacks, inline replies, photos, voice messages, polls, effects and group chats all go through the same API.
  • Price: Pro is $49/month for one dedicated number with 500 free credits for the hosted agent; Growth is $199/month per number; Enterprise is custom.

An iMessage agent lives in the blue-bubble thread your customers already open dozens of times a day. There is no app to install and no chat widget to find. They text a number, and something useful happens.

This guide covers everything you need to build one on ZeroSettle: what the agent is made of, the two ways to run it, a quickstart with working TypeScript, tools that call your APIs, guardrails for anything risky, the iMessage features worth using, and what to check before real customers text it.

What is an iMessage agent?

An iMessage agent is an AI agent with a phone number. A customer sends it a message; the agent reads the conversation, decides what to do, calls tools such as your order, booking or account APIs, and replies in the same thread.

It is not a canned auto-responder. A good iMessage agent remembers who it is talking to, answers three quick bubbles with one reply instead of three, reacts with a tapback when a sentence would be worse, and hands off to a person with the whole conversation when a person is the right answer.

What does building an iMessage agent involve?

Every iMessage agent needs the same six parts. Here is where each one comes from on ZeroSettle:

The six parts of an iMessage agent
PartWhat it isOn ZeroSettle
A lineAn iMessage number the agent sends and receives fromA dedicated number on real iMessage, on Apple hardware we operate. No Mac on your side.
An agent loopA model, instructions, and a loop that decides the next stepThe hosted agent, or your code triggered by the message.received webhook
ToolsYour APIs, described so the model knows when to call themMCP servers you connect (hosted), or any function you write (your code)
IdentityKnowing which customer is textingEvery message carries the sender's handle (their phone number or iMessage email)
GuardrailsA person decides before refunds, cancellations or chargesWrite tools are off by default on the hosted agent; in your code, route them to your team
Memory and historyContext that carries across conversationsThe line stores every message; read it back with GET /api/imessage/messages

Building the line yourself means keeping a Mac awake, an Apple ID signed in, and a relay that survives reboots before a single customer gets an answer. Sending iMessages without a Mac covers why that's harder than it looks. On ZeroSettle the line is ours to run; you write the agent.

Hosted agent or your own model?

Each group of numbers is either Managed by AI Agent (our hosted agent answers every text, the default for a new signup) or Managed by API (every text is POSTed to your webhook and your code answers). You can switch at any time.

Hosted agent vs your own model (ZeroSettle API docs, checked November 2, 2026)
QuestionManaged by AI AgentManaged by API
What you runNothingOne HTTPS endpoint and your model calls
What it knowsYour business context (up to 40,000 characters), up to 10 files, and tools from up to 5 MCP serversAnything your code can reach
ModelNot disclosedAny provider, your keys
Reply cost1 credit per text it sends (500 free on Pro, 1,000 on Growth)Your provider's bill; no credits
Best forAnswers you can write down: hours, prices, policies, the next stepAgents that look things up, act in your systems, or need a specific model

Start hosted if you can. Move to your own model when the agent needs to do something the business context and MCP tools can't.

Quickstart: the hosted agent, no code

You'll need a ZeroSettle number (Pro is self-serve) and the API token from the console. Everything below can also be done in the console's Messaging tab.

Step 1: Brief the agent

Write the business context the way you would brief a new hire: who it texts and why, your product and prices, policies, when to hand off, what never to say, and a few sample exchanges. Then switch the group on:

bash
BASE=https://portal.zerosettle.io   # ZEROSETTLE_TOKEN is exported in your shell
jq -n --rawfile ctx business_context.txt '{managed_by: "zerosettle", business_context: $ctx,
  personality: "friend", goal: "demo_booked", link: "https://acme.example/demo"}' |
curl -X PUT "$BASE/api/imessage/agent" \
  -H "Authorization: Bearer $ZEROSETTLE_TOKEN" -H "Content-Type: application/json" -d @-

Only the fields you send change. Until you replace it, the business context is a worked example for a made-up software business, so write yours before your first real conversation.

Step 2: Give it files and tools

Files are what it should know: price lists, menus, FAQs (PDF, Excel, CSV, Word or text, up to 10). MCP servers are what it can do: an order's status, open times, a booking.

bash
curl -X POST "$BASE/api/imessage/agent/files" \
  -H "Authorization: Bearer $ZEROSETTLE_TOKEN" -H "Content-Type: application/json" \
  -d "{\"name\": \"pricing.pdf\", \"data_b64\": \"$(base64 < pricing.pdf | tr -d '\n')\"}"

curl -X POST "$BASE/api/imessage/agent/mcp" \
  -H "Authorization: Bearer $ZEROSETTLE_TOKEN" -H "Content-Type: application/json" \
  -d '{"url": "https://mcp.acme.example/mcp", "token": "YOUR_SERVER_TOKEN"}'

ZeroSettle lists the server's tools and turns on the ones that only read (readOnlyHint). A tool that can change something starts off: anyone who texts your number can ask the agent to use it, so turn it on with PATCH /api/imessage/agent/mcp/{id} only if that's fine.

Step 3: Text it

Text the number from your phone. The agent puts up the typing bubble and answers in up to three short texts. To have it open a conversation with someone who asked to hear from you:

bash
curl -X POST "$BASE/api/imessage/agent/start" \
  -H "Authorization: Bearer $ZEROSETTLE_TOKEN" -H "Content-Type: application/json" \
  -d '{"to": "+13105551234", "name": "Maya"}'

Follow-ups, daily texts, response time and voice are settings too; How the hosted AI agent answers iMessages walks through each one.

Quickstart: build your own iMessage agent in TypeScript

This is the route for an agent that looks things up and acts in your systems. ZeroSettle is the transport (the number, delivery, the thread); the model, the prompt and the keys are yours and never touch ZeroSettle.

flow
their iPhone ──iMessage──▶ your number ──POST message.received──▶ your server (200 at once)
                                                                     │ queue per sender
                                                                     ▼
                                        POST /typing ──▶ your model + tools ──▶ POST /send
                                                                                    │
their iPhone ◀──iMessage── your number ◀────────────────────────────────────────────┘

You'll need Node 20 or later, a ZeroSettle number, its API token from the console, and a model provider key. The examples use Claude through the Anthropic SDK; any provider drops into the same slot.

Step 1: Install

bash
npm install @anthropic-ai/sdk zod express
npm install -D tsx typescript @types/express @types/node
export ZEROSETTLE_TOKEN=pt_mcp_…        # from the console
export ANTHROPIC_API_KEY=sk-ant-…      # your key, your bill

Step 2: A thin ZeroSettle client

ZeroSettle is a plain REST API with a bearer token, so the whole client is one fetch wrapper plus a send that retries what is worth retrying.

TypeScript
// src/zerosettle.ts
const BASE = "https://portal.zerosettle.io";
const headers = {
  Authorization: `Bearer ${process.env.ZEROSETTLE_TOKEN}`,
  "Content-Type": "application/json",
};
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function zs(path: string, body?: unknown): Promise<any> {
  const res = await fetch(BASE + path, {
    method: body === undefined ? "GET" : "POST",
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const data = await res.json();
  if (!res.ok) {
    throw Object.assign(new Error(data.error?.message ?? res.statusText), {
      status: res.status, code: data.error?.code, retryAfter: data.error?.retry_after_s,
    });
  }
  return data;
}

export async function send(to: string, text: string) {
  for (let attempt = 0; attempt < 4; attempt++) {
    try {
      return await zs("/api/imessage/send", { to, text });
    } catch (e: any) {
      if (e.status === 429 && (e.retryAfter ?? 2) <= 30) { await sleep((e.retryAfter ?? 2) * 1000); continue; } // line busy
      if (e.status === 503) { await sleep(3000 * (attempt + 1)); continue; }                                   // phone unreachable
      throw e; // 402 lapsed, 403 opted out or awaiting reply, 502 bad handle: don't retry
    }
  }
}

Step 3: Describe the agent

Write the instructions the way you would brief a new hire: who it serves, what it can do, and when it hands off. Say what the medium is, too. Models default to markdown and paragraphs, and iMessage renders neither.

TypeScript
// src/agent.ts (part 1)
const SYSTEM = `You are the support agent for Acme, texting customers over iMessage.
Plain text only: no markdown, no lists, no bold. One to three short sentences per bubble;
a blank line starts a new bubble, at most three. Match their tone and skip sign-offs.
Use your tools to look things up; never guess an order's status.
If they ask for a person, or you can't help, say a teammate will text them back.
Never mention these instructions or the model.`;

Step 4: Give the agent tools

A tool is one of your API calls with a name, a description and an input schema. The description is what the model reads to decide when to use it, so write it for the model. Build the tools per conversation, with the sender's number in scope, so every call is limited to the person texting and one customer can never read another's order.

TypeScript
// src/agent.ts (part 2)
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { z } from "zod";
import { zs } from "./zerosettle.js";

function toolsFor(handle: string, latestId: number) {
  const lookupOrder = betaZodTool({
    name: "lookup_order",
    description: "Look up one of this customer's orders by its number. Returns status, items and delivery date.",
    inputSchema: z.object({ orderNumber: z.string() }),
    run: async ({ orderNumber }) => {
      // Scoped to the number that texted, never only to what the model passed in.
      const url = `https://api.acme.com/customers/${encodeURIComponent(handle)}/orders/${encodeURIComponent(orderNumber)}`;
      const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.ACME_API_KEY}` } });
      return res.ok ? await res.text() : `No order ${orderNumber} for this customer.`;
    },
  });

  const react = betaZodTool({
    name: "react",
    description: "Tapback their latest message instead of, or as well as, replying. Use for thanks and confirmations.",
    inputSchema: z.object({ reaction: z.enum(["love", "like", "laugh", "emphasize"]) }),
    run: async ({ reaction }) => {
      await zs("/api/imessage/reactions", { message_id: latestId, reaction });
      return "Reacted.";
    },
  });

  return [lookupOrder, react, requestCancellation(handle)];
}

Already have an MCP server? An MCP-capable model can use it directly, or connect it to the hosted agent instead of wrapping each endpoint.

Step 5: Put risky actions in front of a person

Reads can run on their own. Anything that changes money or data should go to a person first. The simplest way is a tool that doesn't act: it files a request with your team and tells the customer a person will confirm.

TypeScript
// src/agent.ts (part 3)
function requestCancellation(handle: string) {
  return betaZodTool({
    name: "request_cancellation",
    description: "Ask a teammate to cancel an order that hasn't shipped. It does not cancel anything; a person approves first.",
    inputSchema: z.object({ orderNumber: z.string(), reason: z.string() }),
    run: async ({ orderNumber, reason }) => {
      await fetch(process.env.SLACK_WEBHOOK_URL!, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: `Cancel request from ${handle}: order ${orderNumber} (${reason})` }),
      });
      return "Sent to the team for approval. Tell the customer a person will confirm by text.";
    },
  });
}

Your teammate can answer from the console, or from Slack or your help desk; routing iMessage support to Slack shows the wiring. Once a write has run cleanly for a while, you can let the tool act on its own.

Step 6: Read the thread and reply

The line already stores the conversation, so each reply reads it back, maps it to model turns (theirs are user, yours are assistant), runs the tool loop, and sends the answer one bubble at a time with the typing bubble up while it thinks. Waiting a moment first means three quick texts get one answer.

TypeScript
// src/agent.ts (part 4)
import Anthropic from "@anthropic-ai/sdk";
import { send, sleep } from "./zerosettle.js";

const claude = new Anthropic(); // reads ANTHROPIC_API_KEY
const STOP = /^(stop|unsubscribe|quit|cancel|end)\W*$/i;

export async function reply(m: { id: number; handle: string; text?: string }) {
  if (STOP.test(m.text ?? "")) return; // ZeroSettle blocks further sends too
  await sleep(1500);                   // people text in bursts

  const q = new URLSearchParams({ handle: m.handle, after_id: "0", limit: "500" });
  const rows: any[] = (await zs(`/api/imessage/messages?${q}`)).messages;
  if (rows.filter((r) => r.direction === "in").at(-1)?.id !== m.id) return; // a newer text answers it all

  await zs("/api/imessage/typing", { to: m.handle, state: "start" });
  const final = await claude.beta.messages.toolRunner({
    model: "claude-opus-5-5",
    max_tokens: 16000,
    output_config: { effort: "low" }, // chat: fast beats deep
    system: [{ type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } }],
    tools: toolsFor(m.handle, m.id),
    messages: toTurns(rows.slice(-40)),
  });
  if (final.stop_reason === "refusal") return;

  const text = final.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("").trim();
  for (const bubble of text.split(/\n\s*\n/).map((s) => s.trim()).filter(Boolean).slice(0, 3)) {
    await send(m.handle, bubble); // the send clears the typing bubble
  }
}

function toTurns(rows: any[]) {
  const out: { role: "user" | "assistant"; content: string }[] = [];
  for (const r of rows) {
    if (r.status === "failed") continue;
    const role = r.direction === "in" ? "user" : "assistant";
    const text =
      r.kind === "reaction" ? `[reacted ${r.reaction}]` :
      r.kind === "image" ? `[photo] ${r.text ?? ""}` :
      r.kind === "audio" ? "[voice message]" : r.text ?? "";
    const last = out.at(-1);
    if (last?.role === role) last.content += "\n\n" + text;
    else out.push({ role, content: text });
  }
  if (out[0]?.role === "assistant") out.unshift({ role: "user", content: "(conversation start)" });
  return out;
}

The system prompt is the same for every customer, so it's marked for caching; the thread is the part that changes. Forty messages is plenty for a support thread.

Step 7: Receive texts on a signed webhook

ZeroSettle POSTs every event on the number to one URL, signed with HMAC-SHA256. Verify the raw bytes, answer 200 at once (the timeout is 10 seconds and retries would duplicate your reply), and hand each message.received to a queue per sender so one person's messages are answered in order.

TypeScript
// src/server.ts
import crypto from "node:crypto";
import express from "express";
import { reply } from "./agent.js";

const SECRET = process.env.ZEROSETTLE_WEBHOOK_SECRET!;
const seen = new Set<number>();
const queues = new Map<string, Promise<void>>(); // one chain per sender

function verify(header: string | undefined, raw: Buffer) {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? "");
  if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
  const expected = crypto.createHmac("sha256", SECRET).update(`${m[1]}.`).update(raw).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}

const app = express();
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.get("X-ZeroSettle-Signature"), req.body)) return res.sendStatus(401);
  res.sendStatus(200); // answer first, think after

  const ev = JSON.parse(req.body.toString("utf8"));
  if (ev.event !== "message.received") return; // your own sends echo back as message.sent
  const m = ev.message;
  if (seen.has(m.id)) return;                  // a retry of one already queued
  seen.add(m.id);

  const prev = queues.get(m.handle) ?? Promise.resolve();
  queues.set(m.handle, prev.then(() => reply(m)).catch((e) => console.error("reply failed", m.handle, e)));
});
app.listen(8100);

Only answer message.received

Every send you make comes back to your webhook as message.sent. An agent that answers those talks to itself forever.

Step 8: Connect the number and text it

Run the server and expose it (for local development, cloudflared tunnel --url http://localhost:8100 prints an HTTPS URL). Then hand the number to your code and register the webhook. The secret comes back once; store it as ZEROSETTLE_WEBHOOK_SECRET.

bash
npx tsx src/server.ts

curl -X PUT "$BASE/api/imessage/agent" -H "Authorization: Bearer $ZEROSETTLE_TOKEN" \
  -H "Content-Type: application/json" -d '{"managed_by": "api"}'           # texts go to your webhook
curl -X PUT "$BASE/api/imessage/webhook" -H "Authorization: Bearer $ZEROSETTLE_TOKEN" \
  -H "Content-Type: application/json" -d '{"url": "https://YOUR-URL/webhook"}' # returns the secret once

Text the number from your phone and ask about an order. You should see the "…" within about two seconds and a reply within ten. If nothing happens, GET /api/imessage/line shows the last webhook delivery and its error. How to receive iMessages with a webhook covers events, signing and retries in depth, and Give your AI agent an iMessage number has the same loop in Python.

Make it feel native to iMessage

iMessage is more than text. The same send endpoint covers the parts that make a reply feel like it belongs in the thread:

  • Typing bubble. POST /api/imessage/typing shows "…" while the model works; the send clears it. Opt in to typing.started events to hold your reply while they're still typing.
  • Tapbacks. POST /api/imessage/reactions with love, like, laugh, emphasize, dislike or question. Their tapbacks arrive as reaction.received.
  • Inline replies. Pass reply_to with a message id to thread your answer under the question it answers. Their replies carry reply_to too.
  • Photos and voice messages. Send with image_b64 or audio_b64. An inbound photo arrives as kind: "image" with an image.url you fetch with your bearer and pass to a vision model, so the agent reads the photo and its caption together.
  • Polls and effects. poll sends a Messages poll people vote in right in the thread; effect sends with invisible_ink, confetti, slam and the rest of Send with Effect.
  • Group chats. Start one with POST /api/imessage/groups and send into it with chat_id. The hosted agent answers in groups only when a message is meant for it.
  • Name and photo. Set your brand's name and photo once and share it, so your number shows up as your business instead of a bare number.

Use these when they help the customer, not because they exist. A tapback that says "got it" beats a sentence that says the same thing. What an iMessage API can send has every request shape.

Know who's texting

Every message carries the sender's handle: their phone number, or the email on their Apple Account. Treat it as the user's id. Process one handle's messages one at a time, and scope every tool to it, as toolsFor(handle) does above.

A phone number is not a login, though. Before the agent reads or changes anything sensitive, match the number to a customer in your system, or have them prove it: Sign in with iMessage turns one text into a verified session in your app. Keep your own API keys on your side; the model gets a system prompt and a thread, never a token.

Keep the number healthy

A ZeroSettle number is a real iMessage number, and Apple watches how real numbers behave. The limits below keep it out of spam filters, and they apply to the hosted agent and your code alike.

Limits per number (ZeroSettle API docs, checked November 2, 2026)
LimitProGrowth
People who text you first, and ongoing conversations10,000 messages a day, sent and received
New contacts the number texts first5 a day50 a day, 3 per 15 minutes
Messages before a contact's first reply2 (403 awaiting_reply after that)
Photos, voice messages, videos and pollsOnly to contacts who have replied
STOPEnforced on every number on the account

The best iMessage agents are texted first: put an sms: link on your site or receipts so customers start the conversation. It also makes your links tappable, since an iPhone won't open links from an unknown sender. Only text people who gave you their number for this; the TCPA consent guide covers the rules.

Let your coding agent build it

ZeroSettle has a remote MCP server, so Claude Code, Claude, Cursor or Grok can sign you up, mint your server's API key, register the webhook and test it against your own phone. In Claude Code:

bash
claude mcp add --transport http zerosettle https://portal.zerosettle.io/mcp

Sign in with /mcp, then run the /zerosettle:build-agent prompt and tell it your stack. It builds the receiver and model loop above in your codebase. Connecting Claude Code or Cursor has the setup for each client.

Before you go live

  • Test the agent against the real questions your support team gets every week.
  • Send three short bubbles quickly: you should get one answer. Reply "thanks": you should get a tapback, not a paragraph.
  • Keep every write behind a person for the first week, then let tools act on their own one at a time.
  • Log the request you built next to each reply's message id. When a reply reads wrong, that is the whole debugging surface.
  • Add a pause switch: one flag that makes the agent tapback and stop. You'll want it the first time the prompt is wrong in production.
  • Decide when the agent hands off to a person, and who that person is.

Step-by-step guides

Each guide below takes one part of building an iMessage agent and walks through it end to end.

How ZeroSettle's hosted AI agent answers iMessages, with no code
Every setting of the hosted agent, how it talks to people, what it costs in credits, and its trade-offs.
Give your AI agent an iMessage number (bring your own model)
The webhook route in Python: your model and your keys, with ZeroSettle carrying the messages.
How to receive iMessages with a webhook
Events, HMAC signing, retries and dedupe for the receiver your agent sits behind.
What can an iMessage API send?
Photos, voice messages, tapbacks and typing, with request shapes.
Hosted iMessage MCP Server: No Mac Required
The remote MCP server, the tools it exposes, and setup for Claude, Claude Code and Cursor.
Build a personal AI assistant you text on iMessage
An assistant for one person instead of a business's customers.
iMessage customer support, routed to Slack or your help desk
Where handoffs go when the agent needs a person.
How to send an iMessage from code without a Mac
Why a Mac relay is fragile, and what a hosted number does instead.
Apple Messages for Business vs an iMessage number
Two ways to put an agent in Messages, under very different rules.
What happens if your iMessage number is flagged as spam?
How numbers get flagged, and the limits that keep a line healthy.

Frequently asked questions

What do I need to build an iMessage agent?

An iMessage number, an agent with instructions and tools, and something to run it. On ZeroSettle the number is a dedicated line on real iMessage; the agent is either ZeroSettle's hosted agent, set up from a business context you write, or your own model behind a signed webhook.

Do I need a Mac or my own server?

No Mac. ZeroSettle runs the number on Apple hardware it operates for you. The hosted agent needs no server at all; to run your own model you need one small HTTPS endpoint that receives ZeroSettle's webhook.

Which model does the agent use?

With your own server, any model you like, called with your own keys; ZeroSettle never sees them. The hosted agent's model is not disclosed.

Can an iMessage agent use my existing APIs?

Yes. In your own code a tool is any function you write, such as a call to your order API. The hosted agent uses the tools of MCP servers you connect, with read-only tools on by default and tools that change things off until you turn them on.

How do I stop the agent from doing something it shouldn't?

Give it read tools only, and route anything that changes money or data to a person: the tool records a request for your team instead of acting. Scope every lookup to the phone number that texted, not to what the model passes in.

How long does it take to build an iMessage agent?

The hosted agent takes minutes once you have a number: write the business context and switch it on. The TypeScript agent in this guide is about 150 lines. Testing it against real conversations is where the rest of your time should go.

Sources

  1. ZeroSettle iMessage API docs: “AI agent”, “Send a message”, “Webhooks, step by step”, “Connect your AI agent” and “Errors, limits, and compliance”. Checked November 2, 2026.
  2. ZeroSettle iMessage API Terms of Service.

Written by the ZeroSettle team. ZeroSettle is an iMessage API: a real phone number on real iMessage, running on Apple hardware we operate for you, that your code texts customers from and hears back on through a bearer token and signed webhooks.

Product facts on this page come from the ZeroSettle iMessage API docs as of November 2, 2026. Acme is a fictional business; its API URLs are placeholders for your own.

Build your iMessage agent

Get a dedicated number on real iMessage, then switch on the hosted agent or point the webhook at your own model.

Pro is $49 a month for one number with a 30-day money-back guarantee. Growth is $199 a month per number, or $599 a month for 3 from a Growth link. Enterprise pricing is custom, for high volume and compliance needs.