Get an email or a text when Claude Code finishes a task

Add a Stop hook and a Notification hook to ~/.claude/settings.json that run one script. On Stop, the script pipes Claude's last_assistant_message into unitpost email send. On a permission_prompt notification, it runs unitpost sms send, on paid plans. Keep the key limited to emails:send and sms:send, store it with unitpost login --key so it never touches the repo, and make the script exit 0.

Unitpost ·

Claude Code runs shell commands, called hooks, at fixed points in a session. Two make a notification: Stop, when Claude finishes responding, and Notification, when Claude Code would alert you. Point both at a script that calls the unitpost CLI, and the alert reaches your inbox or phone instead of a terminal you stopped watching.

What do the Stop and Notification hooks tell you?

Per the Claude Code hooks reference, a command hook receives the event as JSON on stdin.

  • Stop runs when the main agent has finished responding. It doesn't run when you interrupt, and an API error fires StopFailure instead. It takes no matcher. Its input carries last_assistant_message, the text of Claude's final response, and background_tasks, any work still running.
  • Notification matches on the notification type. permission_prompt fires when Claude needs you to approve a tool use and the prompt has waited about six seconds. idle_prompt fires when Claude finished about 60 seconds ago and you haven't typed since. Its input carries message, an optional title and notification_type. It can't block anything.

So send the email on Stop, where the final message fits as a body, and the text on permission_prompt, when Claude is stuck until you answer.

Create a key that can only send

In Unitpost, open Settings → API keys. On a paid plan, open Advanced and keep only emails:send and sms:send. On the free plan, the narrowest key is the Sending only preset: emails:send, plus campaigns:send and reads such as contacts, segments, templates and domains. SMS isn't on the free plan anyway.

Then store it under its own profile:

Bash
unitpost login --key --profile notify

With no value after --key, the CLI asks for the key without echoing it, checks it against the API, and stores it in the macOS Keychain, or a file only you can read elsewhere. Nothing lands in the repo, the settings file or your shell history.

Two things can still surprise you. The CLI reads UNITPOST_API_KEY before any saved profile, and a hook inherits Claude Code's environment, so a key exported in your shell wins over notify. unitpost doctor says when both are present. And the from address must be on a domain verified in the key's workspace, or the send answers 403 domain_not_verified.

Write the hook script

Save this as ~/.claude/hooks/unitpost-notify.sh and chmod +x it. It uses jq, as Claude Code's own examples do. The flags are those of unitpost email send and unitpost sms send: the API's request fields, kebab-cased, plus --text-file, which reads the body from a file or - for stdin.

Bash
#!/bin/bash
# ~/.claude/hooks/unitpost-notify.sh: one script for both hooks.
input=$(cat)
event=$(jq -r '.hook_event_name' <<<"$input")
project=$(basename "$(jq -r '.cwd' <<<"$input")")
log="$HOME/.claude/hooks/unitpost-notify.log"

if [ "$event" = "Stop" ]; then
  # Background work still running: Claude paused, it didn't finish.
  if jq -e '(.background_tasks // []) | length > 0' <<<"$input" >/dev/null; then
    exit 0
  fi
  jq -r '.last_assistant_message // "Claude Code finished."' <<<"$input" |
    unitpost email send --profile notify \
      --from "Claude Code <claude@yourdomain.com>" \
      --to you@yourdomain.com \
      --subject "Claude Code finished in $project" \
      --text-file - >/dev/null 2>>"$log"
fi

if [ "$event" = "Notification" ]; then
  unitpost sms send --profile notify \
    --to +15551234567 \
    --body "Claude Code in $project: $(jq -r '.message' <<<"$input")" \
    >/dev/null 2>>"$log"
fi

# Never exit 2: on Stop it tells Claude to keep working.
exit 0

Three lines matter more than they look:

  • The background_tasks check. Per Claude Code's docs, this array tells a finished session apart from one paused until background work wakes it. Mailing on a pause reports a task that isn't done.
  • >/dev/null. When its stdout isn't a terminal, the CLI prints one JSON document. A hook that prints a JSON object has it read as hook output, so discard the CLI's.
  • exit 0. The CLI exits 2 when the command itself is wrong, such as a missing required flag. A Stop hook that exits 2 stops Claude from stopping and hands it your stderr as the reason. Errors go to the log instead.

The email body is Claude's last message, which can quote code from the repo. Send it to an address you'd trust with that code.

Add the hooks to settings.json

Put them in ~/.claude/settings.json, which applies to all your projects and stays on your machine. .claude/settings.json in the project is the shared one and gets committed. The shape below is the documented one: event, matcher group, then the handlers.

cURL
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/hooks/unitpost-notify.sh",
            "timeout": 30
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "permission_prompt",
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/hooks/unitpost-notify.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}

If the file already has a hooks key, add Stop and Notification next to the events already there rather than replacing it. timeout is in seconds, and a command hook's default is 600. The hooks run synchronously on purpose: with "async": true, a claude -p run kills any async hook still running at teardown, which can cut off the send.

Type /hooks in Claude Code to see both listed. To test the email without waiting for Claude, feed the script a Stop event by hand:

Bash
echo '{"hook_event_name":"Stop","cwd":"/tmp/demo","last_assistant_message":"Hook test"}' \
  | ~/.claude/hooks/unitpost-notify.sh

What does the text need?

unitpost sms send needs a paid plan, a number in E.164 format, and an active sender in the workspace that reaches your country. In the US that is a 10DLC or toll-free number registered for its use case. Unitpost picks the sender for you when you leave out --from.

Keep the body to one segment: 160 GSM-7 characters, or 70 once an emoji or a curly quote appears. Notification's message is short, such as "Claude needs your permission", so the script's text fits.

The CLI sends an Idempotency-Key with every send, so its own retry after a timeout can't send twice.

Does Codex CLI have the same thing?

Codex has a notify setting in ~/.codex/config.toml. OpenAI's advanced configuration docs show it as notify = ["python3", "/path/to/notify.py"]. Codex runs that program on agent-turn-complete, currently the only event, and passes the event as a single JSON argument, not on stdin. It carries type, thread-id, turn-id, cwd, input-messages and last-assistant-message.

So the same idea works: a script that reads $1 and pipes last-assistant-message into unitpost email send. There is no Codex counterpart to permission_prompt for an external program. Approval requests reach only the built-in tui.notifications, so a text when Codex waits on you isn't possible this way.

Frequently asked questions

Why do I get an email after every reply?

Because Stop fires whenever Claude finishes responding, not only when a long task is done. If you only want mail when you've walked away, hook the email to Notification with the idle_prompt matcher instead: it fires about 60 seconds after Claude finishes, and only if you haven't typed since.

Can Claude Code text me on the free plan?

No. SMS is on paid plans only. On the free plan, unitpost sms send fails with 403 limit_reached, and the error details name feature_not_in_plan on smsChannel, so a different key won't help. The email hook works on every plan.

Why doesn't my hook run?

Claude Code holds back every hook, including those in ~/.claude/settings.json, until you accept the workspace trust dialog for the folder. Type /hooks to confirm both are listed. permission_prompt and idle_prompt fire only when you seem to be away from the terminal. If the hook ran but nothing arrived, read the script's log file.

Free to start

Start sending for free

$0/ mo

  • 5,000 emails / mo — no credit card
  • Your domain with DKIM, SPF & DMARC
  • REST API, SMTP & official SDKs
  • Visual editor + code templates
  • Open & click tracking + signed webhooks
  • 10,000 contacts · automatic suppression