---
title: "Get an email or a text when Claude Code finishes a task"
description: "Claude Code Stop and Notification hooks that run the unitpost CLI: an email when Claude finishes, a text when it waits for your approval."
url: https://www.unitpost.com/blog/agents/claude-code-notify-email-sms
section: Blog
updated: 2026-11-02
---
# 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.

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

> Claude Code Stop and Notification hooks that run the unitpost CLI: an email when Claude finishes, a text when it waits for your approval.

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 (https://code.claude.com/docs/en/hooks), 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.

```bash
{
  "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 (https://developers.openai.com/codex/config-advanced) 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.

## FAQ

### 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.

## Related

- [Why domain verification should never be a one-time check](https://www.unitpost.com/blog/concepts/continuous-domain-verification): DNS changes after you verify a domain. How continuous re-verification with a grace period catches regressions without turning a transient blip into an outage.
- [Why DKIM verification fails (and how to fix each cause)](https://www.unitpost.com/blog/troubleshooting/dkim-verification-failing): Why a DKIM check fails after you've published the key: a selector mismatch, a truncated public key, DNS flattening, or a message changed in transit.
- [Email bounce codes explained: hard, soft and policy rejections](https://www.unitpost.com/blog/deliverability/email-bounce-codes-explained): What 421 4.7.28, 550 5.7.1, 550 5.1.1, 452 4.2.2 and 550 5.7.26 mean, and whether to retry, slow down, fix authentication or remove the address.
