Skip to content
Labs15 min read

How to Put Grok Bot on iMessage: How We Built It

Grok Bot isn't on iMessage, so we put it there. What Keiki is, how the bridge works, and the 10 steps we took, with code and the bugs we hit on a real phone.

A painted dome ceiling seen from below: gilded ribs framing a blue sky of clouds, angels and cherubs around a central medallion

Short answer: Grok Bot doesn't run on iMessage by itself, but you can connect it. Keiki gives an agent an iMessage number and hands each text to a small server we call a bridge, which triggers your own Grok Bot through a webhook routine and texts its answer, files and Tapbacks back to the same thread.

You text a number: "turn our go-to-market notes into a PDF playbook." About a minute later the PDF shows up in the same iMessage thread, with a short text under it. You didn't text a chatbot that pretends to be Grok. Your own Grok Bot did the work: your routine, your connected accounts, your approval rules. It just did it over iMessage.

Grok Bot doesn't run on iMessage. It's its own app. We put it there in two days, without touching Grok's code, using Keiki and a small bridge. This post covers what we built, why we built it, how it works, and every step it took, including what broke. You can follow it to do the same with your own Grok Bot, or with any agent that can be triggered by a webhook. If you're new to iMessage agents, start with the complete guide to building iMessage agents.

What Keiki is

Keiki is hosted infrastructure for agents that talk to people over messaging. You bring the agent. Keiki gives it a real phone number on iMessage, SMS or WhatsApp, plus everything around that number:

  • The line and delivery. Sending and receiving, typing indicators, threads and Tapbacks, and retries that never send a text twice.
  • Conversation history. Every message stored per conversation, so your agent always knows what was said.
  • Media. Photos, PDFs, voice notes and videos turned into text and descriptions an agent can read, before your code sees them.
  • Files and a sandbox. A place for an agent to create or download a file and attach it to the thread.
  • Harnesses. Keiki can run the agent's model loop for you, or hand each message to code you run yourself. The second option is the external harness, and it's what made this project possible.

The idea is that every team building a texting agent shouldn't have to rebuild phone lines, delivery, media handling and identity. Keiki handles the messaging layer and you focus on what your agent actually does.

Why we put Grok Bot on iMessage

Grok Bot is a capable agent: it researches, writes documents, makes images and video, and acts in your accounts. But you have to open its app to use it, and the app you already open dozens of times a day is Messages.

We also wanted a hard test for Keiki. Wiring up an agent we wrote ourselves is easy, because we can change both sides. Grok Bot was the opposite: an agent we don't control, with no iMessage support, no "Sign in with Grok," and nothing to integrate with except a webhook. If Keiki could make that feel native, it could do it for anyone's agent.

It ended up testing almost everything Keiki does:

What Grok Bot neededWhat Keiki provided
An iMessage numberA Keiki line on the agent
Run someone else's agent, not oursExternal harness in inbox mode
Know who's texting, safelyAuthenticated agent and conversation IDs on every turn
Read photos, PDFs, voice notes, videoMedia processing before the turn arrives
Send real files backSandbox plus download_url and send_file
Tasks longer than ten minutesRenewable turn leases
Never double-send after a crashIdempotency keys on every send
Feel like texting a personThreads, Tapbacks, typing, multiple bubbles

Words you'll see

A few terms come up a lot. Here's what they mean in plain English:

  • Turn: one incoming text and everything your agent does to answer it. Keiki creates a turn for each message.
  • Harness: the thing that runs an agent's brain. Keiki can run it for you, or with the external harness it hands each turn to your own code.
  • Bridge: the small server we wrote. It sits between Keiki and Grok Bot and passes messages both ways.
  • Webhook routine: a Grok Bot task that starts when a web address is called, instead of when you click a button in the app.
  • Lease: a hold on a turn. While the bridge keeps renewing it, Keiki knows the task is still being worked on.
  • Private link (capability URL): a long, random web address that works for one task only and then expires. Having the link is the permission.
  • Idempotency key: a label attached to each send, so if the same send is retried, the person still gets the text only once.

How it works

There are four pieces:

  1. A Keiki agent with an iMessage line. Keiki owns the number, delivery, history and media processing. It runs on the external harness, so instead of running its own model it hands each turn to your code.
  2. A bridge. A small server you run. It claims each turn from Keiki, works out whose Grok Bot the turn belongs to, and triggers that person's routine.
  3. A Grok Bot routine. Webhook-triggered and created in each person's own Grok Bot. Grok does the actual work.
  4. A reply path. Grok posts its answer, progress and files back to the bridge, and the bridge delivers them to the right thread through Keiki.

Here's one message from start to finish:

Code
You (iMessage)
  โ†’ Keiki line: stores the message, turns attachments into text, queues a turn
  โ†’ Bridge: claims the turn, checks the agent, finds your Grok Bot by conversation
  โ†’ Your Grok Bot routine: webhook with a short preview + private context_url and reply_url
  โ†’ Grok fetches context_url: full message, recent history, style guide, upload_url
  โ†’ Grok works (minutes or hours), posting progress to reply_url
  โ†’ Bridge: delivers the answer, files, Tapbacks and thread replies through Keiki
  โ†’ You (iMessage)

One rule keeps it safe: the bridge, not the model, decides who a message belongs to, where it goes and which account runs it. Grok never sees a phone number and can't choose a recipient. Every URL it gets is a single-purpose, expiring capability.

What you need

  • A Keiki account and an iMessage line. Buy one on the agent's Channels page, or import one you already have.
  • A Grok Bot account with access to its desktop app, to create the routine.
  • A place to run a small server. A laptop is fine to start; Step 10 moves it to a host.
  • Bun or Node, and the @onkeiki/channels package.

The build, step by step

We built this in the order below. Each step solved the problem the previous one exposed, and each one has a What broke for us note, because most of the bugs only showed up on a real phone.

Step 1: Create the agent and put it on the external harness

Create an agent, attach the iMessage line, and set its harness to external in inbox mode. In inbox mode Keiki queues each turn and your bridge claims it by polling, so you need no public URL, no signing secret, and nothing is lost while the bridge is offline. With the SDK:

TypeScript
harness: {
  type: 'external',
  connectivity: 'inbox',
}

Then create a harness API key for this agent. The bridge uses it to claim turns. Before polling, check that the key belongs to this one agent and no other. That way a key with wider access can never route someone else's messages:

TypeScript
const KEIKI_API = 'https://onkeiki.com/api/v1'
const get = (path: string) =>
  fetch(`${KEIKI_API}/${path}`, { headers: { Authorization: `Bearer ${HARNESS_KEY}` } }).then(r => r.json())

const ping = await get('harness/ping')
if (ping.agentId !== AGENT_ID || ping.agents !== 1 || ping.harness !== 'external') {
  throw new Error('Use a key bound only to this agent')
}

Turn off the Keiki features Grok already brings: memory, browser and loops. Leave media on (it turns incoming attachments into text) and sandbox on (outgoing files pass through it).

What broke for us: we started in push mode, which needs a public URL. The first tunnel, ngrok, failed with ERR_NGROK_334 because its default hostname was already in use by another app. Cloudflare Quick Tunnel worked with no account. Our very first test texts also failed with relay_offline, simply because nothing was polling for the agent yet. Push mode also capped turns at ten minutes, which mattered later (Step 6).

Step 2: Poll for turns

The bridge long-polls Keiki for the next turn. Each turn carries its own short-lived turnToken and turnUrl, which are the only credentials you use to reply:

TypeScript
while (true) {
  const { turn } = await get('harness/inbox/next?wait=25')
  if (!turn) continue
  if (turn.agentId !== AGENT_ID) throw new Error('Invalid turn')
  await dispatch(turn, turn.turnToken)
}

Never work out who's texting from a phone number in the message. Keiki tells you which agent and conversation the turn belongs to, and that can't be faked. Turn those two IDs into a single key (a "scope"), and use only that key to look up whose Grok Bot should run the task:

TypeScript
const scope = sha256(JSON.stringify([turn.agentId, turn.conversationId]))

If you use push mode instead, verify the signature first with verifyKeikiDispatch from @onkeiki/channels, before reading any field. The external harness reference covers both modes.

Step 3: Create the Grok Bot routine

In Grok Bot's desktop app, create a routine with only a webhook trigger and no schedule. Its instructions do the heavy lifting. Ours tells Grok to:

  • Read the webhook fields request_id, conversation_id, context_url and reply_url, then GET the context first.
  • Only call URLs that use HTTPS, have the bridge's exact hostname, and match /context/, /reply/ or /upload/ followed by 64 hex characters. Never follow redirects.
  • Reply by POSTing { request_id, status, answer } to reply_url, where status is progress, answer or blocked.
  • Keep its normal approval rules: return blocked and say what to approve in Grok Bot, and never treat an iMessage "yes" as approval.

Open the routine's Webhook panel and copy two values: the webhook URL and the sender key.

Step 4: Let each person connect their own Grok Bot

There's no "Sign in with Grok", so connecting is bring-your-own-webhook. When someone texts connect, the bridge replies with a private, single-use link that expires in 30 minutes. The page shows the routine prompt to paste into their own Grok Bot, then asks for the webhook URL and sender key. The key goes into a form, never into iMessage. Store both encrypted, keyed by the scope from Step 2. Add status and disconnect commands so people can check or remove their connection.

Then the bridge triggers that person's routine:

TypeScript
await fetch(credentials.url, {
  method: 'POST',
  headers: { Authorization: `Bearer ${credentials.key}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    request_id: turn.turnId,
    conversation_id: scope,
    context_url: `${BRIDGE}/context/${capability}`,
    reply_url: `${BRIDGE}/reply/${capability}`,
    message: text.slice(0, 280),
  }),
})

Don't retry a webhook call that timed out. You can't tell whether Grok started, and a retry might do the task twice. Tell the person to check Grok Bot instead.

What broke for us: saving the setup form returned Forbidden in every real browser, while every test passed. The page sent Referrer-Policy: no-referrer, which makes browsers submit forms with Origin: null, and the bridge correctly rejects that. Our tests had set the Origin header by hand. Switching to strict-origin let the real Origin through and still kept the private link out of the Referer.

Step 5: Let Grok pull the context

Notice the webhook above carries only a 280-character preview. The full message and history live behind context_url. Grok fetches it first and must check that request_id and conversation_id match before answering. The response holds the full message and up to 24 earlier entries from this conversation, oldest first. It also includes the current style guide, so every connected person gets updates without re-pasting their routine.

Make both URLs random 256-bit capabilities that expire when the task ends. Store only their hashes.

What broke for us: the first version pushed the whole history inside the webhook. "Remember this phrase" came back in about 20 seconds, but the follow-up failed: Grok reported a truncated event. The number had also been used by an earlier bot, so old history went along too. Cutting Keiki's history limit from 40 to 12 made the payload smaller but didn't fix it. Pulling did. Only messages handled by the bridge enter the context now, and recall works in both directions.

Step 6: Keep long tasks alive

Real Grok work often takes longer than ten minutes. When the bridge claims a turn, Keiki holds it for a while (a lease). Renew the lease while Grok works, and the turn stays open as long as it needs:

TypeScript
const res = await fetch(`${turn.keiki.turnUrl}/lease`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${turnToken}` },
})
const { leaseExpiresAt } = await res.json()

Our bridge keeps a task's reply path open for up to 24 hours. If the bridge restarts, it reclaims the same turn with a fresh token and keeps the same Grok job instead of triggering Grok again. While Grok works, send typing, and send one "still working" notice only if a minute passes without any update from Grok.

To check this, we ran one real task for 11 minutes 38 seconds with a follow-up queued behind it. The answer arrived, then the follow-up was answered correctly.

What broke for us: a message sent during a restart reached the old tunnel and was lost, which is why inbox mode beats push mode for this. A job reclaimed after a restart also kept using its expired token partway through sending a file; it now picks up the fresh token. And the first "still working" notice fired at nine minutes even when Grok had already sent its own update, so now any progress from Grok cancels it.

Step 7: Deliver replies exactly once

When Grok sends its final answer, text it to the person, then mark the turn as done. Only mark it done if the text actually went out:

TypeScript
import { createTurnClient } from '@onkeiki/channels'

const client = createTurnClient({ turnUrl: turn.keiki.turnUrl, turnToken })
const sent = await client.message(answer, { idempotencyKey: `grok:${run.id}:final` })
if (!sent.ok) throw new Error(`delivery failed: ${sent.code}`) // keep the run pending and retry
await client.complete({ status: 'completed' })

Give every send a stable idempotency key, so a retry after a crash never texts the same thing twice. Save the result before sending, so a failed delivery can be retried without asking Grok again.

Step 8: Files in and files out

Incoming: you don't have to do anything. With media on, Keiki turns attachments into text before the turn reaches the bridge. Documents and PDFs become text, images get descriptions, voice notes are transcribed, and videos are described from sampled frames plus audio. Grok reads that in the context.

Outgoing: the context response includes an upload_url. Grok creates the real file, POSTs its raw bytes with ?name=<filename> and a Content-Type, gets back a file_id, and lists it in its final reply's files array. The bridge then has Keiki's download_url tool fetch the file into the turn's sandbox and send_file attach it to the conversation. Grok never sees a phone number, so it can't choose who gets the file. We limit files to 25 MB each, five per task and 100 MB per job.

In live tests Grok sent back a PDF, a PNG and a short MP4, and read the right values out of a new PDF, image and video.

What broke for us: a text file arrived with exactly the right bytes but with internal IDs glued onto its name. We moved the IDs into a parent folder, and the next test arrived as grok-exact-check.txt. Video tests also couldn't run on the Mac because the decoder's memory limit was rejected there, so they run in Linux CI, which matches production.

Step 9: Make it feel like iMessage

A bot that only sends plain bubbles feels like a bot. In the context, give each incoming message a private message_ref. Grok can then:

  • Add reply_to to answer inside a native thread.
  • Add a reaction to leave a Tapback. A plain "thanks" can get a ๐Ÿ‘ and nothing else.
  • Send bubbles: ["first thought", "next thought"] to split a reply into one to three short texts.

The bridge maps each reference back to the real message and performs the action on the authenticated conversation, never on an ID found in user text. If a threaded reply or reaction can't be confirmed, it falls back to plain text and says so.

Then tune the tone. Out of the box, Grok texted like it was writing an email: too long, too formal, and it would announce a retry before it had started one. The routine now aims for 20โ€“60 words unless someone asks for more, calculates numbers instead of estimating them, never reports work it hasn't done, and keeps jokes small.

Step 10: Move it off your laptop

A bridge on a laptop goes silent whenever the laptop sleeps. The tunnel also gets a new address on every restart, and every connected routine then has to be updated. So we moved the bridge to a Docker container on Railway:

  • Health check on /health, and a permanent HTTPS domain.
  • A persistent volume for the encrypted SQLite state.
  • Exactly one replica, because SQLite on a volume can't be shared across instances.
  • Start with GROK_BRIDGE_MODE=paused: health checks pass, but it accepts no traffic. Copy the state over, check it and update each routine's approved hostname. Then switch to active and send a real iMessage to confirm the hosted reply path.

What broke for us: on the Mac we updated each routine's approved address by hand after every restart, and the bot went quiet whenever the Mac slept. Starting paused let us move the data and check it before the hosted bridge answered anyone.

What works and what doesn't yet

Working now:

  • Tasks run by each person's own Grok Bot, with follow-ups that remember context
  • Tasks longer than ten minutes, with progress updates
  • Files both ways, plus voice notes and video in
  • Thread replies and Tapbacks
  • Many people, each with their own account
  • An always-on host

Not yet:

  • Approvals stay in Grok Bot. Replying "yes" in iMessage doesn't approve an email, purchase or other action that Grok Bot gates. Grok says what to approve in the app.
  • No stop or redirect. A new text waits behind the running task instead of steering it.
  • Your Tapbacks aren't passed on. Grok can react to you, but your reactions don't reach Grok yet.

Lessons

  • Pull context, don't push it. A pointer is small and can't be truncated, and the receiver checks it matches.
  • Plan for long tasks from day one. Real agents take longer than ten minutes, and renewable leases are cheaper than rewriting later.
  • Keep identity on the server. The authenticated conversation chooses the account, the recipient and the thread. The model never does.
  • Test on a real phone. The Origin bug, the truncated event and the filename bug all passed mocked tests and failed for real.
  • Tone is a feature. A correct answer in an email-length bubble still feels wrong in Messages. Short defaults, honest progress and a Tapback when that's all a message needs did more for the experience than any single capability.

What this means if you're building an agent

Grok Bot was the hardest kind of agent to put on a phone: one we couldn't modify, reachable only by webhook. Everything that made it work (the line, the external harness, authenticated turns, media, files, leases, threads and Tapbacks) is the same thing Keiki gives any agent. If your agent can take an HTTP request, it can have a phone number. Start on Keiki, or read the complete guide to building iMessage agents first.

FAQ

Can Grok send text messages? Not on its own. Grok Bot has no phone number. Connected through Keiki as in this guide, it can read and reply to iMessages, send files and react with Tapbacks.

How do I add AI to iMessage? Give your AI agent a phone number with a service like Keiki, then connect the agent's code to that number. If the agent already exists somewhere else, like Grok Bot, a small bridge passes texts to it and sends its answers back. The guide to adding iMessage to your AI agent covers the general case.

Is Grok Bot on iMessage? Not natively. Grok Bot is its own app. You can connect your own Grok Bot to an iMessage number with a webhook routine and a small bridge, as this tutorial shows.

Does each person need their own Grok Bot account? Yes. Each person connects their own routine through a private link, and the bridge routes every message by the authenticated conversation, so nobody can reach anyone else's bot.

Can Grok Bot send files over iMessage? Yes. Grok uploads the file to a private upload URL and the bridge attaches it to the thread through Keiki. In our setup each file can be up to 25 MB, with up to five per task.

What happens when a task takes a long time? The bridge renews the turn's lease, so the task stays open for up to 24 hours. Grok can send progress along the way, and the final answer arrives in the same thread.

Can I approve Grok actions by replying yes in iMessage? No. Actions that need approval in Grok Bot, such as sending an email or making a purchase, still need approval in the Grok Bot app.

Do I need a Mac? No. Keiki provides the iMessage line and the bridge runs anywhere you can host a small server. See How to Run an iMessage Agent Without a Mac.