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:

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.

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 wantUse
To know the turn ended, always, even if the agent crashed out of its own planStop hook
To know it is sitting waiting for your permissionNotification hook, permission_prompt
A summary of what it did, in its wordstell_person
To approve or refuse something before it happensask_person
To answer Claude Code's own permission prompt from your phonethe channel
None of the above, from a CI run rather than your laptopthe 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.