Skip to main content

View source on GitHub

A self-contained example showing how to notify an external URL the moment a conversation finishes, using a Stop hook — instead of finding out only by polling. When the agent reaches a terminal state, the hook POSTs to a URL you control, optionally attaching a payload file’s contents. This is the “push instead of poll” pattern: keep your existing polling loop as a safety net, but let the callback wake you up immediately in the common case. Load Finish Callback
About the badge: clicking it loads the plugin into a fresh conversation, but the /launch route only carries plugins + message — not secrets. Without OH_CALLBACK_URL the hook is a deliberate no-op, so the badge is a “see the plugin load” demo. To actually receive a callback you must supply the secrets, which means the API path below (load_finish_callback.py) or your own call to POST /api/v1/app-conversations with a secrets field.
No customer information lives in this plugin. The callback URL, an optional shared-secret token, and an optional payload file path all come from conversation secrets at start time. The repo ships only a local test receiver so you can prove the end-to-end flow against your own machine.

What’s in the Box

How It Works

Configuration

Everything is supplied as conversation secrets — nothing is hard-coded: Default body when no payload file is given:

Try It End-to-End

You need two things reachable from the sandbox: a running receiver and a public URL that forwards to it. The receiver is stdlib-only; the loader needs requests:
You’ll also need a Cloud API key (export OH_API_KEY="sk-oh-...").

1. Start the receiver

It prints every POST it receives (pretty-printed JSON) and replies 204.

2. Expose it to the internet

The sandbox runs in the cloud, so it needs a public URL to reach your laptop. Use any tunnel, e.g.:
Note the public https://… URL it gives you. That’s your OH_CALLBACK_URL.

3. Start a conversation with the plugin loaded

Use the bundled turnkey helper. It loads the plugin and passes the callback settings as conversation secrets, so the Stop hook picks them up as environment variables:
To send your own body instead of the default envelope, add --callback-payload "/path/in/sandbox/body.json" (the path is resolved inside the sandbox, not on your laptop). Prefer the generic loader? load-plugin does the same thing with --secret flags:
Heads-up: the callback fires on every transition to FINISHED — so it also covers follow-up messages you send later, not just the first run.

Verify the Hook Locally (no sandbox needed)

You can exercise the exact hook script against a local receiver in one shell:

The Hook

The magic is in hooks/hooks.json:
How it works:
  1. Stop — runs when the agent tries to finish (the terminal-state moment).
  2. matcher: "*" — Stop hooks aren’t tool-specific, so match everything.
  3. type: "command" — the command is a POSIX-sh script run via /bin/sh -c.
  4. async: true — fire-and-forget, so the callback never delays finishing.
  5. Exit codes: 0 = allow the agent to finish (this hook always does); 2 would block finishing (we deliberately never do that).
The script reads its config from the environment, builds the body, and curls your URL with a short timeout, swallowing errors.
Why inline (not a reference to the bundled on_stop.sh)? When hooks run as a plugin, they execute with the working directory set to the agent’s workspace (not the plugin directory), and there is no plugin-root path variable — so a relative path like hooks/on_stop.sh won’t resolve. on_stop.sh is kept as the readable, locally-testable source of truth; the identical script is embedded inline in hooks.json, which is the copy that actually runs. If you edit the script, re-embed it:

Reliability: callback + polling

The callback is a latency optimization, not a delivery guarantee. It won’t fire if:
  • the sandbox dies or the run errors out before reaching FINISHED,
  • the receiver is down or the URL is unreachable, or
  • the POST times out.
So keep your polling loop as the safety net and treat the callback as the fast path. That’s exactly the hybrid the pattern is designed for: react immediately when the callback arrives, fall back to polling when it doesn’t.

Hook Types

Hooks can intercept different lifecycle events:

Real-World Use Cases

  • Windmill / workflow engines — get pinged when a run finishes instead of polling every few seconds
  • CI pipelines — kick off the next stage the moment the agent is done
  • Dashboards / queues — mark a job complete in real time
  • Chat notifications — post “run finished” to Slack/Teams from your own backend