@pajecawav/opencode-webhooks
v0.2.5
Published
OpenCode plugin that sends webhooks when an agent finishes or gets blocked
Downloads
1,532
Readme
@pajecawav/opencode-webhooks
OpenCode v2 plugin that sends a webhook when an agent finishes its run or gets blocked waiting for you: a permission request, a form (question), or a retry loop.
Zero runtime dependencies for the server plugin; the optional ./tui entrypoint uses
renderers the OpenCode terminal already provides.
Install
Add the plugin to opencode.json(c):
{
"plugins": [
{
"package": "@pajecawav/opencode-webhooks",
"options": {
"url": "https://example.com/hooks/opencode",
},
},
],
}The URL can also come from the OPENCODE_WEBHOOK_URL environment variable.
Options
| Option | Type | Default | Description |
| ----------------- | ------------------------ | ------- | -------------------------------------------------------------------------------------------- |
| url | string | — | Webhook endpoint. Falls back to OPENCODE_WEBHOOK_URL. |
| headers | Record<string, string> | {} | Extra request headers, e.g. {"authorization": "Bearer …"}. |
| secret | string | — | HMAC-SHA256 signing secret. Falls back to OPENCODE_WEBHOOK_SECRET. |
| timeout | number | 10000 | Per-request timeout in ms. |
| retries | number | 5 | Retry attempts after a failure (network error, timeout, 429, 5xx). |
| events | string[] | all | Which event types to send (see below). |
| directory | string | — | Restrict deliveries to this directory when the host provides no plugin location (opencode2). |
| quietWhenViewed | boolean | true | Skip webhooks for finished runs you already viewed (see below). |
| viewedGraceMs | number | 1500 | Grace period for a watching client to mark a run viewed before sending. 0 disables. |
Without a URL the plugin logs a warning and does nothing.
Events
| Type | Meaning |
| ------------------------------- | --------------------------------------------- |
| session.execution.succeeded | Agent finished the run successfully |
| session.execution.failed | Agent run failed |
| session.execution.interrupted | Agent run was interrupted |
| permission.asked | Agent waits for a permission decision |
| form.created | Agent asked a question via a form |
| session.status | Server entered a retry loop (send failures) |
Only events from the exact location (directory + workspace) the plugin is loaded in are delivered.
On hosts that do not provide the plugin location (opencode2 beta), the directory option scopes
deliveries; without it, every session on the server is delivered.
Quiet when viewed
You do not need a webhook for a run you watched finish. OpenCode marks a session viewed when a
client (terminal, web, desktop) is displaying it — the same signal behind unread badges. With
quietWhenViewed (default true), the three session.execution.* events are skipped when the
session was viewed at or after its last idle transition:
- You are watching the session when the run ends → the outcome is marked viewed → no webhook.
- The run ends while you are elsewhere → the session stays unread → webhook. Open it later and the next finished run is quiet again.
- Headless / SDK runs are never viewed → every webhook is sent.
Clients mark a watched run viewed within milliseconds of it finishing, so the plugin waits
viewedGraceMs (default 1.5s) and re-checks once before sending — no race between the webhook and
the viewed marking. Every skip is logged to the OpenCode server log
([opencode-webhooks] skipped … : outcome already viewed), so you can always see why a webhook did
not fire.
Suppression always errs on the side of sending: an unfetchable session or missing timestamps
delivers the webhook, and events that wait for you (permission.asked, form.created,
session.status retry loops) are never suppressed. Set quietWhenViewed: false to get a webhook
for every run again.
Delivery modes
A process-wide mode gates every delivery. /hooks <mode> (or /webhooks <mode> in clients
without the TUI entrypoint) sets it explicitly; without an argument both commands cycle
auto → off → on:
| Mode | Behavior |
| ------ | ----------------------------------------------- |
| auto | Default: deliver, applying "quiet when viewed" |
| off | Drop every webhook |
| on | Deliver everything, ignoring viewed suppression |
The mode is persisted in plugin storage and survives restarts. It is also exposed over the plugin's
RPC (opencode-webhooks) for scripts and other clients:
const client = OpenCode.make({ baseUrl: "http://localhost:4096" });
const hooks = client.rpc(WebhooksRpc); // import { WebhooksRpc } from "@pajecawav/opencode-webhooks/rpc"
await hooks.setMode({ mode: "off" });
await hooks.status({}); // { mode: "off" }
for await (const event of hooks.events.subscribe("mode.changed")) console.log(event.data);Terminal indicator
The package also ships a TUI entrypoint. OpenCode loads it automatically from the ./tui export:
the footer shows the current mode (hooks: auto), and /hooks [auto|off|on] (also in the command
palette) sets or cycles the mode with a toast confirmation. When a finished run is suppressed as
already viewed, a toast explains why no notification was sent.
Payload
{
"source": "opencode-webhooks",
"version": 1,
"type": "session.execution.succeeded",
"timestamp": "2026-09-05T12:00:00.000Z",
"location": { "directory": "/repo", "workspaceID": "ws_1" },
"event": { "/* raw OpenCode event */": "…" },
"session": {
"id": "ses_1",
"title": "Fix the login bug",
"agent": "build",
"model": { "providerID": "anthropic", "id": "claude-sonnet-4-5" },
"cost": 0.42,
"outcome": "succeeded",
"time": { "created": 1, "updated": 2, "idle": 3, "viewed": 3 },
},
}session is attached best-effort; if it cannot be fetched the webhook is sent without it.
Signature
With secret set, every request carries x-opencode-signature: sha256=<hex> — an HMAC-SHA256 of
the raw body. Verify it:
import { createHmac } from "node:crypto";
const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
if (request.headers["x-opencode-signature"] !== expected) return reply(401);Delivery semantics
- FIFO, one request at a time.
- Failed deliveries are retried up to
retriestimes with exponential backoff (1s doubling to 32s, plus jitter). - Queue holds up to 100 webhooks; on overflow the oldest queued one is dropped.
- Delivery is in-memory: webhooks still queued when OpenCode exits are lost (best-effort flush with a ~2s deadline on plugin unload).
- One deliverer per location. Hosts may keep several live plugin instances (per-session or per-location registries); instances of the same location (directory + workspace) collapse via takeover, while instances of different locations coexist and each delivers only its own events. A single process-wide slot would let the last-booted location (often the home directory) silently swallow every other project's webhooks.
Example receiver
node examples/receiver/server.js # http://localhost:8787
OPENCODE_WEBHOOK_SECRET=dev node examples/receiver/server.js 9595Development
pnpm install
pnpm build # tsdown → dist/
pnpm test # vitest
pnpm lint # oxlint + tsc + format + publintKeep src/version.ts in sync with package.json (used for the user-agent header).
