> ## Documentation Index
> Fetch the complete documentation index at: https://obversa.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Notifications

> @obversa/notify-webhook: one message per interesting run event, posted to a URL you supply.

`@obversa/notify-webhook` tells somebody what a run is doing without them
watching it: it turns the run's own events into one message each and posts
them to a URL you supply. Each body has a `text` field, so a Slack, Discord
or Teams incoming webhook shows it with no code of your own; the rest of
the body is structured, for a relay that wants the parts.

## Install

<CodeGroup>
  ```bash npm theme={null}
  npm install @obversa/notify-webhook
  ```

  ```bash pnpm theme={null}
  pnpm add @obversa/notify-webhook
  ```
</CodeGroup>

Included in `@obversa/obversa`.

## Requirements

A URL to post to, supplied at run time.

## Quickstart

Make the notifier, hand its `onEvent` to `run`, and await `done()` before
the process exits:

```ts examples/notify-webhook.ts (excerpt) {1,11,14} theme={null}
const notifier = webhookNotifier({
  url,
  onError: (error) => { console.error(`the channel did not take a message: ${error.message}`); },
});

// The compiler checks the wiring: `run` hands a `LoopEvent` to a handler the
// notifier declared for its own `RunEvent`, so the two must stay structurally
// compatible. That catches `kind`, `ts` or `path` changing type or going
// missing. It does NOT catch a renamed event kind, because kinds are plain
// strings, nor a payload field disappearing, because every one is optional.
const onEvent: (event: LoopEvent) => void = notifier.onEvent;
const result = await run(brief, { onEvent });
// Await the posts before the process can exit, or the last message is lost.
await notifier.done();
```

The example starts its own receiver in place of a channel and runs
offline; in your own run, pass your incoming webhook's address as `url`.
Run it with `npx tsx notify-webhook.ts`:

```json Output theme={null}
{
  "run": "pass",
  "drafts": 2,
  "channel": [
    "Run started: brief.",
    "Stage finished: draft (pass)",
    "Stage finished: review (fail)",
    "Sent back: review returned work to draft\nthe second claim has no figure behind it",
    "Stage finished: draft (pass)",
    "Stage finished: review (pass)",
    "Run finished."
  ]
}
```

## Read what it sends

Six moments, one message each:

| Moment | The run event behind it | What the message carries |
| - | - | - |
| `run-started` | `dag:start`, `loop:start` or `workflow:start` | What started |
| `stage-finished` | `dag:node` reaching `done`, at any depth | The stage and how it ended |
| `sent-back` | `dag:kickback`, or a `loop:review` that did not pass | The reason the reviewer gave, and in a graph which stage the work went to |
| `paused` | A stage whose outcome is `paused`, or an ending event with one | The question being asked, and the run's page if it has one |
| `finished` | An ending event whose outcome is `pass` | That it finished; the run's summary is a field on the body, not in the text |
| `failed` | An ending event with any other outcome | Why it ended that way |

The paused message names [the run's page](/docs/driving/monitor) when the run
serves one, so the person can open it and answer, and asks the person's own
question when an `approval()` gate carries one. The sent-back message
carries what the reviewer said; in a graph it names the stage the work went
to, and a loop names no stage because it sends work to its own body. A
stage that's waiting for a person hasn't finished, so it's reported as
paused, and a run with two gates gets a paused message for each.

A run is announced once and ends once: the first graph or loop the run
reports owns it, and that container's ending is the run's, at whatever
depth it sits. News from inside the run isn't filtered by depth. Everything
else a run emits, every engine token included, is ignored. `messageFor(event,
monitor?)` is the mapping from one run event to a `WebhookMessage`
(`{ ts, monitor?, event, text }`), or `undefined` for an event with no
message.

## Options

| Field | Type | Default | Description |
| - | - | - | - |
| `url` | `string` | required | Where to post. |
| `fetch` | `FetchLike` | global `fetch` | The HTTP client, injectable for tests. |
| `onError` | `(error: Error) => void` | none | Called when a post is refused or the endpoint is unreachable. |

## Errors

* **A notification that can't be delivered never fails the run.** The error
  reaches `onError` and the run carries on.
* **Order is kept.** Messages are posted in the order the run made them, so
  one slow post holds back the rest instead of letting them overtake it.
* **A failure is reported from the ending event, never from `error`.** A
  loop can emit `error` in one iteration and pass in the next. **A resumed
  run sends no message of its own**, because no run event says it resumed.

<Accordion title="Full file">
  ```ts examples/notify-webhook.ts theme={null}
  /**
   * Tell somebody what the run is doing, without watching it.
   *
   * `@obversa/notify-webhook` is an `onEvent` consumer: it turns a run's own
   * events into one message each and posts them to a URL you supply. The body
   * carries a `text` field, which is the field a Slack, Discord or Teams
   * incoming webhook renders, so those three need no code of their own.
   *
   * This example runs a small graph offline whose review returns the work once,
   * so the interesting messages all appear: the run started, a stage finished,
   * a reviewer returned work with the reason, and the run finished. The URL is
   * not written down here either: the example starts its own receiver on a port
   * the operating system picks and prints what a channel would have shown. In
   * your own run, pass the address of your Slack incoming webhook instead.
   */
  import { createServer } from 'node:http';
  import type { AddressInfo } from 'node:net';

  import { webhookNotifier, type WebhookMessage } from '@obversa/notify-webhook';
  import { dag, fnJob, run, type LoopEvent } from '@obversa/runtime';

  /** Stands in for the channel. In a real run this is Slack, and you own the URL. */
  const delivered: WebhookMessage[] = [];
  const channel = createServer((request, response) => {
    let body = '';
    request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8'); });
    request.on('end', () => {
      delivered.push(JSON.parse(body) as WebhookMessage);
      response.writeHead(200).end();
    });
  });
  await new Promise<void>((resolve) => { channel.listen(0, '127.0.0.1', resolve); });
  const url = `http://127.0.0.1:${(channel.address() as AddressInfo).port}/`;

  /** The writer. In a real team this is an agent; here it gets it wrong once. */
  let drafts = 0;
  const draft = fnJob('draft', (ctx) => {
    drafts += 1;
    return ctx.lastReview ? `rewritten after: ${ctx.lastReview.summary}` : 'first draft';
  });

  /** The review. It returns the first draft with a reason, then accepts. */
  const review = fnJob('review', () => (drafts === 1
    ? { status: 'fail' as const, summary: 'the second claim has no figure behind it', revision: { target: 'draft', reason: 'the second claim has no figure behind it' } }
    : { status: 'pass' as const, summary: 'both claims carry a figure' }));

  const brief = dag({
    name: 'brief',
    maxKickbacks: 1,
    nodes: {
      draft: { desc: 'Write the brief.', gate: 'A draft exists.', job: draft },
      review: { needs: 'draft', acceptsKickbackTo: ['draft'], desc: 'Check every claim carries a figure.', gate: 'The review returned a verdict.', job: review },
    },
  });

  const notifier = webhookNotifier({
    url,
    onError: (error) => { console.error(`the channel did not take a message: ${error.message}`); },
  });

  // The compiler checks the wiring: `run` hands a `LoopEvent` to a handler the
  // notifier declared for its own `RunEvent`, so the two must stay structurally
  // compatible. That catches `kind`, `ts` or `path` changing type or going
  // missing. It does NOT catch a renamed event kind, because kinds are plain
  // strings, nor a payload field disappearing, because every one is optional.
  const onEvent: (event: LoopEvent) => void = notifier.onEvent;
  const result = await run(brief, { onEvent });
  // Await the posts before the process can exit, or the last message is lost.
  await notifier.done();
  await new Promise<void>((resolve) => { channel.close(() => { resolve(); }); });

  console.log(JSON.stringify({
    run: result.outcome.status,
    drafts,
    channel: delivered.map((message) => message.text),
  }, null, 2));

  /**
   * Part of the documentation proof: it must fail when the behaviour it shows
   * stops happening. A notifier that posted nothing, or that stayed silent when
   * the reviewer sent work back, would otherwise print a passing run.
   */
  const faults: string[] = [];
  const sent = delivered.map((message) => message.event);
  if (!sent.includes('run-started')) faults.push('the channel was never told the run started');
  if (!sent.includes('stage-finished')) faults.push('the channel was never told a stage finished');
  if (!sent.includes('sent-back')) faults.push('the channel was never told the reviewer sent work back');
  if (!sent.includes('finished')) faults.push('the channel was never told the run finished');
  const back = delivered.find((message) => message.event === 'sent-back');
  if (back && !back.text.includes('no figure behind it')) faults.push('the sent-back message dropped the reviewer reason');
  if (delivered.some((message) => message.text.trim() === '')) faults.push('a message carried no text for a channel to render');
  if (drafts !== 2) faults.push(`the draft ran ${drafts} times, so the kickback did not happen`);
  if (faults.length) {
    for (const fault of faults) console.error(fault);
    process.exitCode = 1;
  }
  ```
</Accordion>

## API

* **`webhookNotifier(options)`**: a `WebhookNotifier` with `onEvent` and
  `done()`. [Quickstart](#quickstart).
* **`messageFor(event, monitor?)`**: one event to one message.
  [Read what it sends](#read-what-it-sends).
* **Types**: `WebhookNotifierOptions`, `WebhookMessage`, `MessageEvent`,
  `RunEvent`, `RunEventOutcome`, `FetchLike`.

## Next steps

* [Watch a run in the browser](/docs/driving/monitor): the page a paused message
  links to.
* [Running](/docs/concepts/running): the events every run emits.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.