Skip to content

Notifiers ​

A notifier delivers messages from the engine to the owner: today approval requests, later "stuck" pings and results. It is a small interface you implement or install.

ts
/*
 * Copyright (c) 2026.
 * Author Peter Placzek (tada5hi)
 * For the full copyright and license information,
 * view the LICENSE file that was distributed with this source code.
 */

export type Notification = {
    kind: 'approval' | 'stuck' | 'result' | 'info';
    agent: string;
    runId: string;
    title: string;
    body: string;
    /** Signed token resuming the run (kind 'approval'). */
    resumeToken?: string;
    expiresAt?: Date;
};

export interface INotifier {
    readonly name: string;
    /**
     * Delivers a notification. `idempotencyKey` is stable per logical
     * notification (`runId:approvalId` for approvals), so transports can dedupe.
     */
    notify(n: Notification, opts: { idempotencyKey: string }): Promise<void>;
}
Notification fieldDescription
kindapproval, stuck, result or info. The core engine sends approval
agent, runIdWho is asking
title, bodyHuman-readable text. For approvals the body shows the tool name and its input as JSON
resumeTokenThe signed token for engine.resume() (approvals only)
expiresAtWhen the approval times out (approvals only)

Registering notifiers ​

Register notifiers by name on the engine and reference one from an agent with notify:

ts
const triage = defineAgent({
    name: 'triage',
    model,
    instructions: 'Triage the issue.',
});
const digest = defineAgent({
    name: 'digest',
    model,
    instructions: 'Summarize the day.',
    notify: 'console',
});

const engine = new RunEngine({
    store: new MemoryStore(),
    agents: [triage, digest],
    notifiers: { console: new ConsoleNotifier() },
    resumeSecret: 'change-me',
    maxConcurrentRuns: 8,
    claimTtlMs: 30_000,
});

The engine rejects an agent whose notify names no registered notifier.

Writing a notifier ​

ts
export class ConsoleNotifier implements INotifier {
    readonly name = 'console';

    async notify(n: Notification, opts: { idempotencyKey: string }): Promise<void> {
        console.log(`[${n.kind}] ${n.title} (${opts.idempotencyKey})`);
        console.log(n.body);
        if (n.resumeToken) console.log(`resume token: ${n.resumeToken}`);
    }
}

A notifier that talks to a remote service:

ts
export class WebhookNotifier implements INotifier {
    readonly name = 'webhook';

    constructor(protected readonly url: string) {}

    async notify(n: Notification, opts: { idempotencyKey: string }): Promise<void> {
        const response = await fetch(this.url, {
            method: 'POST',
            headers: {
                'content-type': 'application/json',
                'idempotency-key': opts.idempotencyKey,
            },
            body: JSON.stringify(n),
        });
        // throw on failure: the engine retries the approval notification on the next poll
        if (!response.ok) throw new Error(`Notification failed with ${response.status}.`);
    }
}

Two rules:

  1. Throw on failure. Never swallow a failed send. The engine records waiting.notifiedAt only after notify() resolves, and retries every poll until it does.
  2. Respect idempotencyKey. It is stable per logical notification (<runId>:<approvalId> for approvals). If the engine crashed after a successful send but before recording it, the retry carries the same key; pass it to the transport (an Idempotency-Key header, a hidden marker) so the owner gets one message.

A store lock per approval keeps replicas from sending the same notification at the same time.

Planned notifiers Planned ​

PackageDelivers to
@chamberu/notify-openclawOpenClaw chats via POST /hooks/agent with an Idempotency-Key. See OpenClaw
@chamberu/notify-githubA comment on the run's issue or pull request, deduplicated by a hidden marker