Skip to content
handupbeta
Download

Agent lifecycle hooks (advanced)

View as Markdown

Agent harnesses run hooks at lifecycle events such as “the turn finished”. Wire them to the installed handup CLI to get an info notice for every finished turn, so you can read the result on any device and type a reply. This guide covers the end-of-turn event of each harness, the field holding the final message, and whether a later reply can wake the agent.

Everything here is opt-in. handup never sends turn-end notices by default: omp’s and Claude Code’s built-in notices stay off until you turn them on, and every other harness needs a hook you add yourself.

Harness End-of-turn event Final-text field Can a later reply wake it?
omp built in (session_stop) handled by the extension Yes, through the extension
Claude Code built in (Stop) handled by handup hook claude Yes, through the installed asyncRewake Stop hook
Codex Stop (legacy: notify) last_assistant_message (last-assistant-message) No; only a synchronous Stop hook waiting within its timeout, or on the next prompt
Cursor stop, after afterAgentResponse afterAgentResponse text No; only a synchronous stop hook returning followup_message
Gemini CLI AfterAgent prompt_response No; only a synchronous hook, within its timeout
opencode session.idle plugin event none; read the messages through the SDK Yes, while the server runs: the plugin sends client.session.prompt

Harness contracts were checked against the vendor documentation on 2026-10-07 (sources at the end). Harnesses change these contracts; recheck them when you upgrade.

  • Post with handup ask --kind info and never --wait: a notice needs no decision. Put the message in --summary under a one-line --title.
  • Pass the harness’s session id as --session and the harness name as --agent. Replies are routed by session.
  • Hook stdout belongs to the harness (it may be parsed as JSON or shown to the model). Send handup’s own output to /dev/null.
  • The daemon must be running. If it is not, handup ask fails, the scripts below ignore it, and the agent carries on.
  • A reply is claimed once, by the first consumer: the hooks below, the MCP server, or your own POST /v1/notice-replies call with {"session": "..."} (custom harness). Each returned item has id, title and feedback.

The scripts use POSIX sh and jq. Like omp, they title the notice with the first non-empty line of the message without Markdown markers (at most 120 characters) and cut the summary at 16,000 characters.

Built in, no hook to write: install the native extension with handup integrate omp, then:

Terminal window
handup config set integrations.omp.turn_notice true

Restart omp. Each finished main-session turn becomes a notice, unless the agent already asked handup something in that run; a typed reply wakes or steers omp. Details in omp turn-end notices.

Built in, no hook to write: the Stop hook that handup integrate claude installs (handup hook claude, "asyncRewake": true) posts the notice when you turn it on:

Terminal window
handup config set integrations.claude.turn_notice true # default false

The hook reads the setting at every Stop, so no restart is needed. It posts Claude’s final message (last_assistant_message; title from its first line, summary cut at 16,000 characters) as a notice for the session, then waits for replies as it always does: a typed reply wakes Claude even when idle, OK or Dismiss does nothing (notice replies). No notice is posted when the message is empty or the session is already waiting on a pending approval or question. Stop does not fire when you interrupt Claude. Rerunning handup integrate claude keeps working: it is the same hook.

Notification hook. To hear when Claude waits on you, post a notice from the Notification event. Its input adds message, an optional title and notification_type; filter with the matcher, for example idle_prompt (about 60 seconds after a turn finishes) or permission_prompt (about six seconds after Claude asks for permission; redundant when handup’s PermissionRequest hook already routes permissions). The hook only observes: it cannot answer the prompt.

#!/bin/sh
input=$(cat)
sid=$(printf '%s' "$input" | jq -r '.session_id')
title=$(printf '%s' "$input" | jq -r '.title // .notification_type')
handup ask --kind info --agent claude --session "$sid" --title "Claude: $title" \
--summary "$(printf '%s' "$input" | jq -r '.message')" >/dev/null 2>&1
{"hooks":{"Notification":[{"matcher":"idle_prompt","hooks":[{"type":"command","command":"/home/you/.claude/hooks/handup-notification.sh","timeout":30}]}]}}

idle_prompt and the Stop notice both report a finished turn; enable one.

Add a Stop hook to ~/.codex/hooks.json (or a trusted project’s .codex/hooks.json, or [hooks] in config.toml). Codex skips new or changed hooks until you review and trust them in /hooks. Plain-text stdout from a Stop hook is invalid, so the script prints nothing.

#!/bin/sh
input=$(cat)
msg=$(printf '%s' "$input" | jq -r '.last_assistant_message // "" | .[0:16000]')
[ -n "$msg" ] || exit 0
sid=$(printf '%s' "$input" | jq -r '.session_id')
title=$(printf '%s' "$msg" | jq -Rrs 'split("\n") | map(gsub("^[\\s#>*_-]+|[\\s*_]+$"; "")) | map(select(length > 0)) | (first // "Codex finished") | .[0:120]')
handup ask --kind info --agent codex --session "$sid" \
--title "$title" --summary "$msg" >/dev/null 2>&1
exit 0
{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"/home/you/.codex/hooks/handup-stop.sh","timeout":30}]}]}}

Treat it as notification-only. A background (async) hook cannot wake an idle Codex. A reply continues Codex only if a synchronous Stop hook waits for it within its timeout (in seconds, default 600) and returns {"decision":"block","reason":"..."}, which blocks the turn end while it waits. To read replies with your next prompt, add a UserPromptSubmit hook; its plain-text stdout becomes developer context, not a user message:

#!/bin/sh
sid=$(jq -r '.session_id')
curl -sf --unix-socket "$XDG_RUNTIME_DIR/handup/handup.sock" \
-H 'Content-Type: application/json' http://handup/v1/notice-replies \
-d "$(jq -nc --arg s "$sid" '{session: $s}')" |
jq -r '.[] | "Reply from the human to your notice \(.title): \(.feedback)"'

The legacy notify setting in ~/.codex/config.toml (notify = ["/home/you/.codex/hooks/handup-notify.sh"]) passes one JSON argument, not stdin, with hyphenated fields: type (agent-turn-complete), thread-id, turn-id, cwd, input-messages and last-assistant-message. Read them with printf '%s' "$1" | jq -r '."last-assistant-message"'. It cannot deliver replies.

Cursor’s stop input has status (completed, aborted or error) but no final text; afterAgentResponse carries it in text. Both share conversation_id, which stays the same across turns, so post from afterAgentResponse:

#!/bin/sh
input=$(cat)
msg=$(printf '%s' "$input" | jq -r '.text // "" | .[0:16000]')
[ -n "$msg" ] || exit 0
sid=$(printf '%s' "$input" | jq -r '.conversation_id')
title=$(printf '%s' "$msg" | jq -Rrs 'split("\n") | map(gsub("^[\\s#>*_-]+|[\\s*_]+$"; "")) | map(select(length > 0)) | (first // "Cursor finished") | .[0:120]')
handup ask --kind info --agent cursor --session "$sid" \
--title "$title" --summary "$msg" >/dev/null 2>&1
exit 0
{"version":1,"hooks":{"afterAgentResponse":[{"command":".cursor/hooks/handup-response.sh"}]}}

Project hooks live in .cursor/hooks.json and run from the project root; global hooks live in ~/.cursor/hooks.json. A reply reaches Cursor only synchronously: a stop hook that keeps running until a reply arrives (claim it from POST /v1/notice-replies with {"session": conversation_id}) and prints {"followup_message":"..."}, which Cursor submits as the next user message. The handler’s timeout is in seconds, and loop_limit (default 5) caps automatic follow-ups. Once the hook returns, a later reply cannot wake Cursor.

Add an AfterAgent hook to .gemini/settings.json (or ~/.gemini/settings.json). It fires once per turn after the final response, with the text in prompt_response. Hook timeouts are in milliseconds (default 60000).

#!/bin/sh
input=$(cat)
msg=$(printf '%s' "$input" | jq -r '.prompt_response // "" | .[0:16000]')
[ -n "$msg" ] || exit 0
sid=$(printf '%s' "$input" | jq -r '.session_id')
title=$(printf '%s' "$msg" | jq -Rrs 'split("\n") | map(gsub("^[\\s#>*_-]+|[\\s*_]+$"; "")) | map(select(length > 0)) | (first // "Gemini finished") | .[0:120]')
handup ask --kind info --agent gemini --session "$sid" \
--title "$title" --summary "$msg" >/dev/null 2>&1
exit 0
{"hooks":{"AfterAgent":[{"hooks":[{"name":"handup-turn-end","type":"command","command":"$GEMINI_PROJECT_DIR/.gemini/hooks/handup-after-agent.sh","timeout":60000}]}]}}

All Gemini hooks run synchronously. A hook that waits for a reply within its timeout can return {"decision":"deny","reason":"..."} to send the reply back as a new prompt; there is no wake after the hook returns. These are Gemini CLI contracts; Antigravity CLI was not checked.

opencode has no shell hook: a plugin listens for session.idle, which carries the session id but not the final text. For opencode V1, save as .opencode/plugins/handup-notice.js (or in ~/.config/opencode/plugins/):

import { execFile } from "node:child_process";
export const HandupNotice = async ({ client }) => ({
event: async ({ event }) => {
if (event.type !== "session.idle") return;
const id = event.properties.sessionID;
const { data = [] } = await client.session.messages({ path: { id } });
const last = data
.filter((m) => m.info.role === "assistant" && m.info.time.completed)
.sort((a, b) => b.info.time.completed - a.info.time.completed)[0];
const text = (last?.parts ?? []).filter((p) => p.type === "text").map((p) => p.text).join("\n\n").trim();
if (!text) return;
const title = text.split("\n").map((l) => l.replace(/^[\s#>*_-]+|[\s*_]+$/g, "")).find(Boolean).slice(0, 120);
execFile("handup", ["ask", "--kind", "info", "--agent", "opencode", "--session", id,
"--title", title, "--summary", text.slice(0, 16000)], () => {});
},
});

The plugin picks the latest completed assistant message by time because the list order is not documented. To deliver a reply, the plugin can claim it from POST /v1/notice-replies with {"session": id} and send it with client.session.prompt({ path: { id }, body: { parts: [{ type: "text", text: reply }] } }), which wakes the session. Do not pass noReply: true: it only adds context.

opencode V2 does not run V1 plugins:

  • Import from @opencode/plugin and default-export Plugin.define({ id, setup(ctx) { ... } }); the config key is plugins, not plugin.
  • Read events with for await (const event of ctx.event.subscribe({ signal })); the idle event is still session.idle, but do not assume the V1 event.properties.sessionID location.
  • Read messages with ctx.session.context({ sessionID }): assistant messages have type: "assistant" and a content array instead of info.role and parts.
  • Send a reply with ctx.session.prompt({ sessionID, text: reply }).
  • The event stream does not replay missed events, and a slow consumer holds it up: queue events before waiting on a human.