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.

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 needed | What Keiki provided |
|---|---|
| An iMessage number | A Keiki line on the agent |
| Run someone else's agent, not ours | External harness in inbox mode |
| Know who's texting, safely | Authenticated agent and conversation IDs on every turn |
| Read photos, PDFs, voice notes, video | Media processing before the turn arrives |
| Send real files back | Sandbox plus download_url and send_file |
| Tasks longer than ten minutes | Renewable turn leases |
| Never double-send after a crash | Idempotency keys on every send |
| Feel like texting a person | Threads, 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:
- 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.
- 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.
- A Grok Bot routine. Webhook-triggered and created in each person's own Grok Bot. Grok does the actual work.
- 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:
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/channelspackage.
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:
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:
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:
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:
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_urlandreply_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 }toreply_url, wherestatusisprogress,answerorblocked. - Keep its normal approval rules: return
blockedand 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:
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:
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.
go through every Railway invoice this year and tell me what we actually spent on compute
On it. There are 10 invoices, so give me a few minutes.
Still going: 6 of 10 read.
$4,812 on compute so far this year. It jumped 38% in March, when you added the second worker. Want it month by month?
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:
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.
turn our go-to-market notes into a PDF playbook
On it. Drafting from the notes in your Drive.
12 pages: who we sell to, pricing, the launch plan and a 30-day checklist. Want a one-pager too?
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_toto answer inside a native thread. - Add a
reactionto 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.
name for a coffee cart that only opens at sunrise?
First Light โ
Or Dawn Patrol, if you want it a little surfy
first light. perfect, thanks!!
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 toactiveand 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.
