A WhatsApp message when Claude Code finishes
You gave an agent twenty minutes of work and walked away. Three ways to be told it is done — or to answer the question it is stuck on — on the app already open on your phone.
There are three routes, and they are not alternatives so much as three different jobs. A Stop hook is the terminal telling you it finished; it needs no MCP server and it fires whether or not the agent remembers to. The MCP server lets the agent itself message you, which matters when you want it to say what it did, or to ask you something and wait for the answer. The channel goes one further and moves Claude Code's own permission prompt to your phone, so the dialog waiting in the terminal can be answered from the sofa.
Both send over Meta's official Cloud API, and neither needs a WhatsApp number of your
own, a Meta business account or an approved message template, because the number and the
template are ours. You need an API key, which starts jak_ and is made under
Management then Approvals in the dashboard.
Route one: a Stop hook
Claude Code fires a Stop event when it finishes a turn, and hands the hook
a JSON object on stdin that includes cwd and
last_assistant_message. That is everything a useful notification needs: which
project, and what it said.
Put this in ~/.claude/jabee-notify.sh and make it executable:
#!/usr/bin/env bash
# Claude Code Stop hook -> a WhatsApp message.
set -uo pipefail
payload=$(cat)
# // empty rather than // "unknown": a jq that fails outright returns nothing
# either way, and an empty string is easier to test for than a word that could
# also have come from the payload.
dir=$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null)
said=$(printf '%s' "$payload" \
| jq -r '.last_assistant_message // empty' 2>/dev/null | head -c 600)
where="this project"
[ -n "$dir" ] && where=$(basename "$dir")
# jq builds the body so a quote, an ampersand or a newline in what Claude said
# cannot break the JSON. The message routinely contains all three.
curl -sS --max-time 10 https://api.jabee.ai/api/v1/alerts \
-H "Authorization: Bearer $JABEE_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg t "Claude Code finished in $where" \
--arg b "${said:-Finished with nothing to report.}" \
'{title: $t, body: $b}')" >/dev/null
# Always succeed. A notifier that fails the hook is worse than no notifier,
# and a non-zero exit here means something to Claude Code.
exit 0
Then register it in ~/.claude/settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "~/.claude/jabee-notify.sh" }
]
}
]
}
}
Three things worth knowing before you paste it:
Stoptakes no matcher. It fires on every turn, so if you work in short turns you will get a message per turn. Most people want this on one project rather than globally — put it in that project's.claude/settings.jsoninstead of your home one.$JABEE_KEYhas to exist in the environment Claude Code was started from. Export it from your shell profile; if you would rather not, read it inside the script from a file only you can read.- The script exits 0 whatever happens on purpose. A non-zero exit from a
Stophook is meaningful to Claude Code — exit code 2 stops it stopping — and a notification that accidentally keeps a session alive is a bad notification.
Being told when it needs you, not only when it is done
The more useful alert is often the other one: it has been sitting for four minutes
waiting for you to approve a command. That is the Notification event, which
carries a notification_type you can filter on —
permission_prompt when it wants permission, idle_prompt when it
has been waiting on you.
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [
{ "type": "command", "command": "~/.claude/jabee-notify.sh" }
]
}
]
}
}
The payload for that event has message rather than
last_assistant_message, so adjust the jq path if you point the
same script at both.
Route two: the MCP server, so the agent can ask
A hook can only tell you things. An agent with the MCP server can tell you and ask you, and wait for the answer before it acts. Add it once:
{
"mcpServers": {
"jabee": {
"command": "npx",
"args": ["-y", "@jabee/mcp"],
"env": { "JABEE_API_KEY": "jak_..." }
}
}
}
That gives three tools. tell_person sends a WhatsApp message — the
agent's own version of "I am done", in its own words, which is better than a hook's
because it knows what it actually did. ask_person sends a question with
approve, skip and edit buttons and blocks until somebody taps one, or for
wait_seconds, after which it hands back a request id.
check_approval reads that id later.
The sentence that makes it work in practice goes in your
CLAUDE.md, because an agent will not ask unless it is told to:
Before anything irreversible — a force push, a production deploy, a
migration, deleting a branch, spending money — call ask_person and wait
for the answer. When a long task finishes, call tell_person with one
line on what changed.
Now the twenty-minute run that used to end in a question you were not there to answer ends in a WhatsApp message with three buttons on it. The MCP reference lists every argument.
Route three: the channel, so the prompt itself arrives
The MCP server lets the agent ask you things it was told to ask. The channel handles
the question it was always going to ask anyway: may I run this command? When
Claude Code wants permission for Bash, Write or
Edit, the prompt arrives on WhatsApp with approve, skip and edit buttons.
It ships in the same package as the MCP server, under a second command. Add it to the
project's .mcp.json:
{
"mcpServers": {
"jabee": {
"command": "npx",
"args": ["-y", "--package=@jabee/mcp", "jabee-channel"],
"env": { "JABEE_API_KEY": "jak_..." }
}
}
}
Then start Claude Code with it loaded:
claude --dangerously-load-development-channels server:jabee
The terminal dialog stays open the whole time, and the first answer wins — the keyboard or the phone, whichever you reach first. Walking back to the desk is never the wrong move.
- That flag is required, and it is not our choice. Channels are a
research preview, and during it Claude Code registers only channels on an
Anthropic-maintained allowlist, or ones your own organisation has put in
allowedChannelPlugins. Without one of those the channel is silently not registered, so the flag is the honest instruction rather than a footnote. A Team or Enterprise admin can allowlist it instead and drop the flag. - Edit denies the command and sends Claude your words, which is usually what you meant — "no, the staging one" — rather than a plain refusal you then have to explain when you get back. Approve allows; skip denies.
- If nobody answers, nothing is sent. When the request expires or cannot be delivered, no verdict goes back and the prompt is still sitting at your terminal. An invented denial would kill a job no person actually refused.
- It can answer you back. Claude gets one tool,
reply, for writing to you on WhatsApp. Every reply is a real message billed like any other, so it is told not to narrate its progress with it.
One setting beyond the key: JABEE_PERMISSION_EXPIRY_MINUTES, how long a
prompt stays answerable, 15 by default and no lower than 5.
Claude Code's own channels are Telegram, Discord and iMessage. This is the one for the app that is already open.
Which one to use
| You want | Use |
|---|---|
| To know the turn ended, always, even if the agent crashed out of its own plan | Stop hook |
| To know it is sitting waiting for your permission | Notification hook, permission_prompt |
| A summary of what it did, in its words | tell_person |
| To approve or refuse something before it happens | ask_person |
| To answer Claude Code's own permission prompt from your phone | the channel |
| None of the above, from a CI run rather than your laptop | the alerts endpoint directly |
Most people end up with the Notification hook and ask_person, and skip
the "finished" message entirely — being interrupted when something needs you turns
out to be worth more than being told when it does not.
What it costs
$29 a month for 500 alerts and approvals together, then 3 cents each. The first three are free, so you can wire the hook up and watch one land before paying for anything. There is no per-seat charge and no charge for the MCP server.
One honest caution about the hook: Stop fires every turn, and a chatty
afternoon is a hundred turns. Scope it to the project where you actually walk away, or
use the Notification route, which fires only when you are needed.
Questions
Does this work with Cursor, or only Claude Code?
The MCP server works with anything that speaks MCP over stdio, which includes Cursor,
Claude Desktop and the Claude Agent SDK. Hooks are Claude Code's own mechanism, so the
Stop and Notification routes are Claude Code only; other editors have their own
equivalents and all of them can run a curl.
Do I need a WhatsApp number or a Meta developer account?
No. Neither, at any point. The message goes out on our registered, Meta-verified number using our approved template, so you need an API key and nothing else. There is no number of yours involved, so there is nothing of yours to get banned.
Who can it message?
Your own people, once they have verified their phone — which they do by tapping a link in the dashboard and sending one message. It will not message your customers, and that is deliberate: a shared number messaging strangers is what gets numbers banned.
Why not just use a desktop notification?
Use one if you are at the desk. The case for this is the opposite one: you left, the laptop is lidded or on a VPS, and the thing you want is the phone in your pocket buzzing — on an app you already have open, rather than another one to install and then ignore.
Can the agent get the answer back as text, not just a button?
Yes. ask_person can offer an Edit button, and whatever the person types
comes back as the amendment on the decision, so "approve, but make it 20% not 30%" is a
single reply rather than a refusal and a new prompt.
Why does the channel need a flag with "dangerously" in it?
Because channels are still a research preview. During it, Claude Code registers only
channels on an Anthropic-maintained allowlist or ones your organisation has added to
allowedChannelPlugins; anything else needs
--dangerously-load-development-channels. The flag is about how the channel
is loaded, not about what it does — it runs on your machine, from a package you
can read, and can only reach a phone you have verified. If you would rather not use the
flag, the Notification hook and ask_person need no allowlist at all.
What happens if nobody answers?
The request expires at the time you set — a day by default — and your side is told. Nothing hangs. If the person has no WhatsApp linked, it falls back to email with the same buttons on a page.