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
StopFailureinstead. It takes no matcher. Its input carrieslast_assistant_message, the text of Claude's final response, andbackground_tasks, any work still running. - Notification matches on the notification type.
permission_promptfires when Claude needs you to approve a tool use and the prompt has waited about six seconds.idle_promptfires when Claude finished about 60 seconds ago and you haven't typed since. Its input carriesmessage, an optionaltitleandnotification_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:
unitpost login --key --profile notifyWith 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.
#!/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 0Three lines matter more than they look:
- The
background_taskscheck. 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.
{
"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:
echo '{"hook_event_name":"Stop","cwd":"/tmp/demo","last_assistant_message":"Hook test"}' \
| ~/.claude/hooks/unitpost-notify.shWhat 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.