feat(ao): add OpenClaw notifier plugin for AO escalations

Adds AO notifier-openclaw (webhook-first Phase 0), registry wiring, tests, docs, and BugBot fixes.
This commit is contained in:
prateek 2026-03-09 18:24:30 +05:30 committed by GitHub
parent cc2031f0f6
commit a99d37b1d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1006 additions and 27 deletions

265
DESIGN-OPENCLAW-PLUGIN.md Normal file
View File

@ -0,0 +1,265 @@
# DESIGN: OpenClaw Integration for AO (Revised)
## Executive Summary
This design is revised to match how OpenClaw actually works and to minimize time-to-value.
- Phase 0-2 uses existing primitives:
- AO `notifier-openclaw` (outbound)
- OpenClaw built-in webhook ingress (`POST /hooks/agent`)
- OpenClaw agent `exec` tool and plugin commands (`api.registerCommand`) for reverse control
- No new AO `peer` slot in early phases.
- Full structured peer protocol, HMAC, and RBAC are deferred to Phase 3 (cross-network / multi-tenant hardening).
This ships fast while solving current operational fragility.
## What Was Correct vs What Changes
## Keep
- Escalation envelope concept and session-supervision goals are sound.
- Need bidirectional path and human override.
- Need durable session identity, dead-session detection, crash forensics, and send reliability signal.
## Change
- Do not start with a custom OpenClaw bridge extension for event ingress.
- Do not add AO reverse-command API in Phase 0-2.
- Do not introduce AO `peer` slot yet.
## Ground Truth Architecture
## OpenClaw capabilities (used here)
OpenClaw already supports:
- Webhook ingress: `POST /hooks/agent`, `POST /hooks/wake`
- Plugin system: commands, services, gateway methods/handlers, tools, channels, CLI
- Auto-reply command registration: `api.registerCommand(...)` (no AI turn)
- Agent execution path with shell/exec tools
## AO capabilities (used here)
- Escalation event production via `lifecycle-manager.ts`
- Notifier plugin interface (`notify`)
- Reliable-ish session send path in `session-manager.send(...)` including confirmation heuristics
## Phase 0 Design (Ship in ~1 day)
## Flow
1. AO escalation event triggers `notifier-openclaw.notify(...)`.
2. `notifier-openclaw` posts to OpenClaw webhook:
- `POST http://127.0.0.1:18789/hooks/agent`
- `Authorization: Bearer <hooks.token>`
3. OpenClaw runs an agent turn and delivers to chat channel(s).
4. Human replies in chat; OpenClaw agent runs `ao send/ao kill/ao session ...` via exec tools.
## Request payload (Phase 0)
```json
{
"message": "[AO Escalation] ao-5 failed CI 5 times on feat/ci-auto-injection. Last error: type mismatch in codex plugin. PR: github.com/ComposioHQ/agent-orchestrator/pull/123. Actions available: retry, skip, kill. Context: {\"sessionId\":\"ao-5\",\"projectId\":\"ao\",\"reason\":\"ci_failed\",\"attempts\":5}",
"name": "AO",
"sessionKey": "hook:ao:ao-5",
"wakeMode": "now",
"deliver": true
}
```
## Session key strategy (required)
Use one OpenClaw session per AO session:
- `hook:ao:<ao-session-id>` (examples: `hook:ao:ao-5`, `hook:ao:ao-12`)
Benefits:
- Preserves per-session escalation history.
- Enables continuity for retries/human follow-up.
- Avoids cross-session context bleed.
Security config:
- `hooks.allowRequestSessionKey: true`
- `hooks.allowedSessionKeyPrefixes: ["hook:ao:"]`
## Why this is the right Phase 0
- Zero OpenClaw plugin code required.
- Uses stable OpenClaw ingress/auth/session behavior.
- Immediate bidirectional operations through existing agent exec path.
## Phase 1 Design (Lightweight OpenClaw plugin)
Add a small OpenClaw plugin focused on UX + ops speed, not transport replacement.
## Plugin responsibilities
1. Register auto-reply commands (`api.registerCommand`):
- `/ao status <id>`
- `/ao sessions`
- `/ao retry <id>`
- `/ao kill <id>`
These execute without invoking an AI turn for fast deterministic actions.
2. Register background service (`api.registerService`):
- Periodic AO health polling (`ao session ls/status`), summarize anomalies.
- Trigger chat updates when dead/stuck sessions detected.
3. Keep complex tasks on normal agent path:
- For multi-step remediation, let AI run with exec tools (`ao send`, diagnostics, fixes).
## Phase 2 Design (Structured OpenClaw plugin)
Add structured AO interactions while keeping Phase 0 compatibility.
## Additions
- Gateway HTTP handler(s) in plugin for structured AO event ingress (optional alongside `/hooks/agent`).
- Agent tools for AO structured reads/actions (e.g., `ao_session_info`, `ao_session_send`).
- Better supervisor event formatting and correlation across chat threads.
Still no AO peer slot required.
## Phase 3 Design (Optional hardened peer protocol)
Only if needed (cross-host, multi-tenant, compliance):
- Dedicated AO peer abstraction.
- Signed envelopes (HMAC), replay protection, RBAC, strict command API.
## Escalation Message Contract (Phase 0-2)
Keep escalation semantics consistent even in text form.
Canonical logical shape:
```json
{
"type": "escalation",
"from": "ao-5",
"reason": "ci_failed",
"attempts": 5,
"context": {
"projectId": "ao",
"branch": "feat/ci-auto-injection",
"pr": "https://github.com/.../pull/123",
"lastError": "..."
},
"actions": ["retry", "skip", "kill"]
}
```
In Phase 0 this is embedded in webhook `message` text plus compact JSON context. In Phase 2+ it can be fully structured.
## Session Supervision Requirements (Operational)
## 1) Health monitoring
- AO should emit/update session health snapshots periodically.
- OpenClaw Phase 1 service polls and reports dead/stuck sessions.
Simplest Phase 0 bootstrap:
- AO writes status snapshots to a known file.
- OpenClaw reads during heartbeat cycle and surfaces anomalies.
## 2) Auto-respawn
- If session dies unexpectedly, workflow should attempt `ao session restore <id>` first.
- If restore fails, spawn replacement with preserved task metadata and explicit mapping notice.
## 3) Stable session identity
Persist task identity independent of numeric ID:
- `logicalSessionKey` (task/issue/PR anchored)
- `taskRef`
- `branch`
- `prUrl`
Respawn/replacement must carry the same logical identity.
## 4) Crash forensics
On failure detection, capture before cleanup:
- last pane output (`tmux capture-pane` tail),
- AO/agent/runtime error signature,
- known classifiers (e.g. permission/auth/config crash).
Include this in escalation message.
## 5) `ao send` delivery confidence
Expose send result confidence in escalations/acks:
- `accepted`
- `confirmed`
- `uncertain`
- `failed`
`uncertain` must not be shown as success.
## Graceful Degradation
If OpenClaw is down/unreachable:
- `notifier-openclaw` fails over per policy to:
- desktop notifier and/or
- webhook/file sink for later replay
- AO must log unsent escalation payloads with retry metadata.
## Rate Limiting / Debounce
Avoid chat spam when many sessions fail simultaneously:
- Batch window: e.g. 10-30s aggregation by `projectId/reason`.
- Collapse repeated identical escalations per `sessionId` within cooldown.
- Send summary + top actionable items when burst detected.
## Security by Phase
## Phase 0-1 (localhost)
- Loopback transport + OpenClaw `hooks.token` auth is sufficient.
- No HMAC/RBAC requirement initially.
## Phase 2+
- Add tighter sender policy for plugin commands.
## Phase 3
- HMAC signatures, replay protection, AO-side RBAC for structured API.
## Reference OpenClaw plugin patterns to follow
When implementing Phase 1/2 plugin, model structure after:
- Voice Call plugin (`@openclaw/voice-call`): command + tool + service + RPC pattern.
- Teams/Matrix channel plugins for robust bidirectional routing patterns.
- Memory plugins for slot/service/tool separation patterns.
## `agent-orchestrator.yaml` (Phase 0)
```yaml
defaults:
notifiers: [desktop, openclaw]
notifiers:
openclaw:
plugin: openclaw
url: "http://127.0.0.1:18789/hooks/agent"
token: "${OPENCLAW_HOOKS_TOKEN}"
retries: 3
retryDelayMs: 1000
notificationRouting:
urgent: [openclaw, desktop]
action: [openclaw]
warning: [openclaw]
info: [openclaw]
```
OpenClaw config requirements:
```json5
{
hooks: {
enabled: true,
token: "${OPENCLAW_HOOKS_TOKEN}",
allowRequestSessionKey: true,
allowedSessionKeyPrefixes: ["hook:ao:"],
defaultSessionKey: "hook:ao:default"
}
}
```
## Required AO changes for Phase 0
1. Implement `notifier-openclaw` using webhook POST semantics.
2. Add payload formatter producing action-oriented escalation text + compact context.
3. Add burst control (debounce/batch) in notifier path.
4. Add fallback routing when OpenClaw is unavailable.
## Required AO/OpenClaw changes for Phase 1
1. OpenClaw plugin with `api.registerCommand` for deterministic `/ao ...` commands.
2. OpenClaw service with `api.registerService` for periodic AO health polling.
3. AO metadata additions for logical session identity and crash forensics fields.
## Revised rollout plan
- Phase 0: AO notifier -> OpenClaw `/hooks/agent`, per-session `hook:ao:*` keys, agent exec for reverse actions.
- Phase 1: OpenClaw plugin commands + health polling service.
- Phase 2: Structured plugin handlers/tools for AO supervisor events and richer automation.
- Phase 3: Optional dedicated peer protocol/security hardening.
## Final Recommendation
Use existing mechanisms first: notifier + webhook + exec + plugin commands. This delivers immediate operational value and directly addresses session durability pain. Defer new abstractions until Phase 3, when complexity is justified.

View File

@ -40,6 +40,7 @@
"@composio/ao-plugin-agent-opencode": "workspace:*",
"@composio/ao-plugin-notifier-composio": "workspace:*",
"@composio/ao-plugin-notifier-desktop": "workspace:*",
"@composio/ao-plugin-notifier-openclaw": "workspace:*",
"@composio/ao-plugin-notifier-slack": "workspace:*",
"@composio/ao-plugin-notifier-webhook": "workspace:*",
"@composio/ao-plugin-runtime-process": "workspace:*",

View File

@ -14,6 +14,10 @@
"./types": {
"types": "./dist/types.d.ts",
"import": "./dist/types.js"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js"
}
},
"files": [

View File

@ -185,7 +185,6 @@ describe("loadBuiltins", () => {
});
expect(fakeWebhookNotifier.create).toHaveBeenCalledWith({
plugin: "webhook",
url: "http://127.0.0.1:8787/hook",
retries: 2,
retryDelayMs: 500,
@ -211,11 +210,61 @@ describe("loadBuiltins", () => {
});
expect(fakeWebhookNotifier.create).toHaveBeenCalledWith({
plugin: "webhook",
url: "http://127.0.0.1:8787/custom-hook",
retries: 4,
});
});
it("passes notifier config from config.notifiers when loading builtins", async () => {
const registry = createPluginRegistry();
const fakeOpenClaw = makePlugin("notifier", "openclaw");
const cfg = makeOrchestratorConfig({
notifiers: {
openclaw: {
plugin: "openclaw",
url: "http://127.0.0.1:18789/hooks/agent",
token: "tok",
},
},
});
await registry.loadBuiltins(cfg, async (pkg: string) => {
if (pkg === "@composio/ao-plugin-notifier-openclaw") return fakeOpenClaw;
throw new Error(`Not found: ${pkg}`);
});
expect(fakeOpenClaw.create).toHaveBeenCalledWith({
url: "http://127.0.0.1:18789/hooks/agent",
token: "tok",
});
});
it("does not match notifier key when explicit plugin points to another notifier", async () => {
const registry = createPluginRegistry();
const fakeOpenClaw = makePlugin("notifier", "openclaw");
const fakeWebhook = makePlugin("notifier", "webhook");
const cfg = makeOrchestratorConfig({
notifiers: {
openclaw: {
plugin: "webhook",
url: "http://127.0.0.1:8787/hook",
retries: 3,
},
},
});
await registry.loadBuiltins(cfg, async (pkg: string) => {
if (pkg === "@composio/ao-plugin-notifier-openclaw") return fakeOpenClaw;
if (pkg === "@composio/ao-plugin-notifier-webhook") return fakeWebhook;
throw new Error(`Not found: ${pkg}`);
});
expect(fakeOpenClaw.create).toHaveBeenCalledWith(undefined);
expect(fakeWebhook.create).toHaveBeenCalledWith({
url: "http://127.0.0.1:8787/hook",
retries: 3,
});
});
});
describe("extractPluginConfig (via register with config)", () => {

View File

@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { readLastJsonlEntry } from "../utils.js";
import { isRetryableHttpStatus, normalizeRetryConfig, readLastJsonlEntry } from "../utils.js";
describe("readLastJsonlEntry", () => {
let tmpDir: string;
@ -85,3 +85,32 @@ describe("readLastJsonlEntry", () => {
expect(result!.modifiedAt).toBeInstanceOf(Date);
});
});
describe("retry utilities", () => {
it("marks 429 and 5xx statuses as retryable", () => {
expect(isRetryableHttpStatus(429)).toBe(true);
expect(isRetryableHttpStatus(500)).toBe(true);
expect(isRetryableHttpStatus(503)).toBe(true);
});
it("marks 4xx statuses (except 429) as non-retryable", () => {
expect(isRetryableHttpStatus(400)).toBe(false);
expect(isRetryableHttpStatus(401)).toBe(false);
expect(isRetryableHttpStatus(404)).toBe(false);
});
it("normalizes retry config with defaults", () => {
expect(normalizeRetryConfig(undefined)).toEqual({ retries: 2, retryDelayMs: 1000 });
});
it("normalizes retry config values and clamps invalid input", () => {
expect(normalizeRetryConfig({ retries: 4, retryDelayMs: 250 })).toEqual({
retries: 4,
retryDelayMs: 250,
});
expect(normalizeRetryConfig({ retries: -1, retryDelayMs: -50 })).toEqual({
retries: 0,
retryDelayMs: 1000,
});
});
});

View File

@ -60,7 +60,14 @@ export { generateOrchestratorPrompt } from "./orchestrator-prompt.js";
export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js";
// Shared utilities
export { shellEscape, escapeAppleScript, validateUrl, readLastJsonlEntry } from "./utils.js";
export {
shellEscape,
escapeAppleScript,
validateUrl,
isRetryableHttpStatus,
normalizeRetryConfig,
readLastJsonlEntry,
} from "./utils.js";
export { asValidOpenCodeSessionId } from "./opencode-session-id.js";
export { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
export type { NormalizedOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";

View File

@ -43,6 +43,7 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> =
// Notifiers
{ slot: "notifier", name: "composio", pkg: "@composio/ao-plugin-notifier-composio" },
{ slot: "notifier", name: "desktop", pkg: "@composio/ao-plugin-notifier-desktop" },
{ slot: "notifier", name: "openclaw", pkg: "@composio/ao-plugin-notifier-openclaw" },
{ slot: "notifier", name: "slack", pkg: "@composio/ao-plugin-notifier-slack" },
{ slot: "notifier", name: "webhook", pkg: "@composio/ao-plugin-notifier-webhook" },
// Terminals
@ -56,14 +57,19 @@ function extractPluginConfig(
name: string,
config: OrchestratorConfig,
): Record<string, unknown> | undefined {
// Notifiers are configured under config.notifiers.<id>.
// Match by key (e.g. "openclaw") or explicit plugin field.
if (slot === "notifier") {
for (const notifierConfig of Object.values(config.notifiers)) {
if (
notifierConfig &&
typeof notifierConfig === "object" &&
notifierConfig["plugin"] === name
) {
return notifierConfig;
for (const [notifierName, notifierConfig] of Object.entries(config.notifiers ?? {})) {
if (!notifierConfig || typeof notifierConfig !== "object") continue;
const configuredPlugin = (notifierConfig as Record<string, unknown>)["plugin"];
const hasExplicitPlugin = typeof configuredPlugin === "string" && configuredPlugin.length > 0;
const matches = hasExplicitPlugin
? configuredPlugin === name
: notifierName === name;
if (matches) {
const { plugin: _plugin, ...rest } = notifierConfig as Record<string, unknown>;
return rest;
}
}
}

View File

@ -32,6 +32,30 @@ export function validateUrl(url: string, label: string): void {
}
}
/**
* Returns true if an HTTP status code should be retried.
* Retry only 429 (rate-limit) and 5xx (server) failures.
*/
export function isRetryableHttpStatus(status: number): boolean {
return status === 429 || status >= 500;
}
/**
* Normalize retry config from plugin config with sane defaults.
*/
export function normalizeRetryConfig(
config: Record<string, unknown> | undefined,
defaults: { retries: number; retryDelayMs: number } = { retries: 2, retryDelayMs: 1000 },
): { retries: number; retryDelayMs: number } {
const rawRetries = config?.retries as number | undefined;
const rawDelay = config?.retryDelayMs as number | undefined;
const retries = Number.isFinite(rawRetries) ? Math.max(0, rawRetries ?? 0) : defaults.retries;
const retryDelayMs = Number.isFinite(rawDelay) && (rawDelay ?? -1) >= 0
? (rawDelay as number)
: defaults.retryDelayMs;
return { retries, retryDelayMs };
}
/**
* Read the last line from a file by reading backwards from the end.
* Pure Node.js no external binaries. Handles any file size.
@ -93,7 +117,6 @@ export async function readLastJsonlEntry(
try {
const [line, fileStat] = await Promise.all([readLastLine(filePath), stat(filePath)]);
if (!line) return null;
const parsed: unknown = JSON.parse(line);

View File

@ -23,6 +23,7 @@
},
"devDependencies": {
"@composio/ao-plugin-notifier-desktop": "workspace:*",
"@composio/ao-plugin-notifier-openclaw": "workspace:*",
"@composio/ao-plugin-notifier-slack": "workspace:*",
"@composio/ao-plugin-notifier-webhook": "workspace:*",
"@composio/ao-plugin-notifier-composio": "workspace:*",

View File

@ -0,0 +1,116 @@
/**
* Integration tests for notifier-openclaw.
*
* Mocks network boundary only (global fetch + timers).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { NotifyAction } from "@composio/ao-core";
import openClawPlugin from "@composio/ao-plugin-notifier-openclaw";
import { makeEvent } from "./helpers/event-factory.js";
describe("notifier-openclaw integration", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
delete process.env.OPENCLAW_HOOKS_TOKEN;
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("notify posts OpenClaw hooks payload with per-session key", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = openClawPlugin.create({
url: "http://127.0.0.1:18789/hooks/agent",
token: "tok",
sessionKeyPrefix: "hook:ao:",
});
await notifier.notify(
makeEvent({
type: "reaction.escalated",
priority: "urgent",
sessionId: "ao-12",
message: "CI failed 5 times",
data: { attempts: 5, reason: "ci_failed" },
}),
);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("http://127.0.0.1:18789/hooks/agent");
const body = JSON.parse(opts.body);
expect(body.name).toBe("AO");
expect(body.sessionKey).toBe("hook:ao:ao-12");
expect(body.wakeMode).toBe("now");
expect(body.deliver).toBe(true);
expect(body.message).toContain("[AO URGENT]");
expect(body.message).toContain("CI failed 5 times");
});
it("notifyWithActions formats escalation header/context and appends action labels", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const actions: NotifyAction[] = [{ label: "retry" }, { label: "kill" }];
const notifier = openClawPlugin.create({ token: "tok" });
await notifier.notifyWithActions!(
makeEvent({
priority: "action",
type: "ci.failing",
sessionId: "ao-5",
message: "CI check failed on app-1",
data: { checkName: "lint" },
}),
actions,
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.sessionKey).toBe("hook:ao:ao-5");
expect(body.message).toContain("[AO ACTION] ao-5 ci.failing");
expect(body.message).toContain('Context: {"checkName":"lint"}');
expect(body.message).toContain("Actions available: retry, kill");
});
it("uses explicit deliver=true in hooks payload", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = openClawPlugin.create({
token: "tok",
deliver: true,
wakeMode: "now",
});
await notifier.notify(makeEvent({ sessionId: "ao-21" }));
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.deliver).toBe(true);
expect(body.wakeMode).toBe("now");
});
it("retries 503 then succeeds", async () => {
vi.useFakeTimers();
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 503, text: () => Promise.resolve("down") })
.mockResolvedValueOnce({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = openClawPlugin.create({ token: "tok", retries: 1, retryDelayMs: 100 });
const promise = notifier.notify(makeEvent());
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(100);
expect(fetchMock).toHaveBeenCalledTimes(2);
await promise;
});
});

View File

@ -0,0 +1,44 @@
# notifier-openclaw
OpenClaw notifier plugin for AO escalation events.
## Required OpenClaw config (`openclaw.json`)
```json
{
"hooks": {
"enabled": true,
"token": "<your-hooks-token>",
"allowRequestSessionKey": true,
"allowedSessionKeyPrefixes": ["hook:"]
}
}
```
## AO config (`agent-orchestrator.yaml`)
```yaml
notifiers:
openclaw:
plugin: openclaw
url: http://127.0.0.1:18789/hooks/agent
token: ${OPENCLAW_HOOKS_TOKEN}
```
## Behavior
- Sends `POST /hooks/agent` payloads with per-session key `hook:ao:<sessionId>`.
- Defaults `wakeMode: now` and `deliver: true`.
- Retries on `429` and `5xx` responses with exponential backoff.
## Token rotation
1. Rotate `hooks.token` in OpenClaw.
2. Update `OPENCLAW_HOOKS_TOKEN` used by AO.
3. Verify old token returns `401` and new token returns `200`.
## Known limitation (Phase 0)
- OpenClaw hook ingest is not idempotent by default. Replayed webhook payloads are processed as separate runs.
- Owner: AO integration.
- Follow-up: add stable event id/idempotency key support.

View File

@ -0,0 +1,44 @@
{
"name": "@composio/ao-plugin-notifier-openclaw",
"version": "0.1.0",
"description": "Notifier plugin: OpenClaw webhook notifications",
"license": "MIT",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "https://github.com/ComposioHQ/agent-orchestrator.git",
"directory": "packages/plugins/notifier-openclaw"
},
"homepage": "https://github.com/ComposioHQ/agent-orchestrator",
"bugs": {
"url": "https://github.com/ComposioHQ/agent-orchestrator/issues"
},
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"@composio/ao-core": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.2.3",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { NotifyAction, OrchestratorEvent } from "@composio/ao-core";
import { create, manifest } from "./index.js";
function makeEvent(overrides: Partial<OrchestratorEvent> = {}): OrchestratorEvent {
return {
id: "evt-1",
type: "reaction.escalated",
priority: "urgent",
sessionId: "ao-5",
projectId: "ao",
timestamp: new Date("2026-03-08T12:00:00Z"),
message: "Reaction escalated after retries",
data: { attempts: 5, reason: "ci_failed" },
...overrides,
};
}
describe("notifier-openclaw", () => {
beforeEach(() => {
vi.restoreAllMocks();
delete process.env.OPENCLAW_HOOKS_TOKEN;
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it("has correct manifest", () => {
expect(manifest.name).toBe("openclaw");
expect(manifest.slot).toBe("notifier");
});
it("uses default OpenClaw hooks endpoint", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({ token: "tok" });
await notifier.notify(makeEvent());
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:18789/hooks/agent");
});
it("uses token from OPENCLAW_HOOKS_TOKEN env", async () => {
process.env.OPENCLAW_HOOKS_TOKEN = "env-token";
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create();
await notifier.notify(makeEvent());
const headers = fetchMock.mock.calls[0][1].headers;
expect(headers["Authorization"]).toBe("Bearer env-token");
});
it("warns and sends without Authorization when token missing", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create();
await notifier.notify(makeEvent());
const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>;
expect(headers["Authorization"]).toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No token configured"));
});
it("builds per-session OpenClaw session key", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({ token: "tok", sessionKeyPrefix: "hook:ao:" });
await notifier.notify(makeEvent({ sessionId: "ao-12" }));
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.sessionKey).toBe("hook:ao:ao-12");
});
it("sanitizes invalid characters in session id", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({ token: "tok" });
await notifier.notify(makeEvent({ sessionId: "ao/12?x" }));
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.sessionKey).toBe("hook:ao:ao-12-x");
});
it("notifyWithActions appends action labels", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({ token: "tok" });
const actions: NotifyAction[] = [{ label: "retry" }, { label: "kill" }];
await notifier.notifyWithActions!(makeEvent(), actions);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.message).toContain("Actions available: retry, kill");
});
it("post uses context sessionId when provided", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({ token: "tok" });
await notifier.post!("ready", { sessionId: "ao-77" });
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.sessionKey).toBe("hook:ao:ao-77");
expect(body.message).toBe("ready");
});
it("defaults wakeMode=now and deliver=true", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({ token: "tok" });
await notifier.notify(makeEvent());
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.wakeMode).toBe("now");
expect(body.deliver).toBe(true);
});
it("supports wakeMode=next-heartbeat when configured", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({ token: "tok", wakeMode: "next-heartbeat" });
await notifier.notify(makeEvent());
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.wakeMode).toBe("next-heartbeat");
});
it("retries on 5xx response", async () => {
vi.useFakeTimers();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 503, text: () => Promise.resolve("down") })
.mockResolvedValueOnce({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({ token: "tok", retries: 1, retryDelayMs: 50 });
const promise = notifier.notify(makeEvent());
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(50);
expect(fetchMock).toHaveBeenCalledTimes(2);
await promise;
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Retry 1/1 for session=ao-5 after HTTP 503"),
);
});
it("does not retry on 4xx response", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve("unauthorized") });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({ token: "tok", retries: 2, retryDelayMs: 1 });
await expect(notifier.notify(makeEvent())).rejects.toThrow("OpenClaw webhook failed (401)");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,193 @@
import {
type EventPriority,
type Notifier,
type NotifyAction,
type NotifyContext,
type OrchestratorEvent,
type PluginModule,
} from "@composio/ao-core";
import {
isRetryableHttpStatus,
normalizeRetryConfig,
validateUrl,
} from "@composio/ao-core/utils";
export const manifest = {
name: "openclaw",
slot: "notifier" as const,
description: "Notifier plugin: OpenClaw webhook notifications",
version: "0.1.0",
};
type WakeMode = "now" | "next-heartbeat";
interface OpenClawWebhookPayload {
message: string;
name?: string;
sessionKey?: string;
wakeMode?: WakeMode;
deliver?: boolean;
}
async function postWithRetry(
url: string,
payload: OpenClawWebhookPayload,
headers: Record<string, string>,
retries: number,
retryDelayMs: number,
context: { sessionId: string },
): Promise<void> {
let lastError: Error | undefined;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(payload),
});
if (response.ok) return;
const body = await response.text();
lastError = new Error(`OpenClaw webhook failed (${response.status}): ${body}`);
if (!isRetryableHttpStatus(response.status)) {
throw lastError;
}
if (attempt < retries) {
console.warn(
`[notifier-openclaw] Retry ${attempt + 1}/${retries} for session=${context.sessionId} after HTTP ${response.status}`,
);
}
} catch (err) {
if (err === lastError) throw err;
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < retries) {
console.warn(
`[notifier-openclaw] Retry ${attempt + 1}/${retries} for session=${context.sessionId} after network error: ${lastError.message}`,
);
}
}
if (attempt < retries) {
const delay = retryDelayMs * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}
function sanitizeSessionId(id: string): string {
return id.replace(/[^a-zA-Z0-9:_-]/g, "-");
}
function eventHeadline(event: OrchestratorEvent): string {
const priorityTag: Record<EventPriority, string> = {
urgent: "URGENT",
action: "ACTION",
warning: "WARNING",
info: "INFO",
};
return `[AO ${priorityTag[event.priority]}] ${event.sessionId} ${event.type}`;
}
function stringifyData(data: Record<string, unknown>): string {
const entries = Object.entries(data);
if (entries.length === 0) return "";
return `Context: ${JSON.stringify(data)}`;
}
function formatEscalationMessage(event: OrchestratorEvent): string {
const parts = [eventHeadline(event), event.message, stringifyData(event.data)].filter(Boolean);
return parts.join("\n");
}
function formatActionsLine(actions: NotifyAction[]): string {
if (actions.length === 0) return "";
const labels = actions.map((a) => a.label).join(", ");
return `Actions available: ${labels}`;
}
export function create(config?: Record<string, unknown>): Notifier {
const url = (typeof config?.url === "string" ? config.url : undefined) ??
"http://127.0.0.1:18789/hooks/agent";
const token =
(typeof config?.token === "string" ? config.token : undefined) ??
process.env.OPENCLAW_HOOKS_TOKEN;
const senderName = typeof config?.name === "string" ? config.name : "AO";
const sessionKeyPrefix =
typeof config?.sessionKeyPrefix === "string" ? config.sessionKeyPrefix : "hook:ao:";
const wakeMode: WakeMode = config?.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now";
const deliver = typeof config?.deliver === "boolean" ? config.deliver : true;
const { retries, retryDelayMs } = normalizeRetryConfig(config);
validateUrl(url, "notifier-openclaw");
if (!token) {
console.warn(
"[notifier-openclaw] No token configured (token or OPENCLAW_HOOKS_TOKEN). Sending without Authorization header.",
);
}
async function sendPayload(payload: OpenClawWebhookPayload): Promise<void> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) headers["Authorization"] = `Bearer ${token}`;
const sessionId = payload.sessionKey?.slice(sessionKeyPrefix.length) ?? "default";
await postWithRetry(url, payload, headers, retries, retryDelayMs, { sessionId });
}
return {
name: "openclaw",
async notify(event: OrchestratorEvent): Promise<void> {
const sessionKey = `${sessionKeyPrefix}${sanitizeSessionId(event.sessionId)}`;
await sendPayload({
message: formatEscalationMessage(event),
name: senderName,
sessionKey,
wakeMode,
deliver,
});
},
async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise<void> {
const sessionKey = `${sessionKeyPrefix}${sanitizeSessionId(event.sessionId)}`;
const actionsLine = formatActionsLine(actions);
const message = [formatEscalationMessage(event), actionsLine].filter(Boolean).join("\n");
await sendPayload({
message,
name: senderName,
sessionKey,
wakeMode,
deliver,
});
},
async post(message: string, context?: NotifyContext): Promise<string | null> {
const sessionId = context?.sessionId ? sanitizeSessionId(context.sessionId) : "default";
const sessionKey = `${sessionKeyPrefix}${sessionId}`;
await sendPayload({
message,
name: senderName,
sessionKey,
wakeMode,
deliver,
});
return null;
},
};
}
export default { manifest, create } satisfies PluginModule<Notifier>;

View File

@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@ -1,11 +1,15 @@
import {
validateUrl,
type PluginModule,
type Notifier,
type OrchestratorEvent,
type NotifyAction,
type NotifyContext,
} from "@composio/ao-core";
import {
isRetryableHttpStatus,
normalizeRetryConfig,
validateUrl,
} from "@composio/ao-core/utils";
export const manifest = {
name: "webhook",
@ -35,15 +39,6 @@ interface WebhookPayload {
context?: NotifyContext;
}
/**
* Returns true if the HTTP status code should be retried.
* Only 429 (Too Many Requests) and 5xx (server errors) are retryable.
* 4xx client errors (400, 401, 403, 404, etc.) are permanent failures.
*/
function isRetryableStatus(status: number): boolean {
return status === 429 || status >= 500;
}
async function postWithRetry(
url: string,
payload: WebhookPayload,
@ -70,7 +65,7 @@ async function postWithRetry(
lastError = new Error(`Webhook POST failed (${response.status}): ${body}`);
// Only retry on 429 or 5xx — 4xx client errors are permanent
if (!isRetryableStatus(response.status)) {
if (!isRetryableHttpStatus(response.status)) {
throw lastError;
}
} catch (err) {
@ -110,10 +105,7 @@ export function create(config?: Record<string, unknown>): Notifier {
if (typeof v === "string") customHeaders[k] = v;
}
}
const rawRetries = (config?.retries as number) ?? 2;
const rawDelay = (config?.retryDelayMs as number) ?? 1000;
const retries = Number.isFinite(rawRetries) ? Math.max(0, rawRetries) : 2;
const retryDelayMs = Number.isFinite(rawDelay) && rawDelay >= 0 ? rawDelay : 1000;
const { retries, retryDelayMs } = normalizeRetryConfig(config);
if (!url) {
console.warn("[notifier-webhook] No url configured — notifications will be no-ops");

View File

@ -62,6 +62,9 @@ importers:
'@composio/ao-plugin-notifier-desktop':
specifier: workspace:*
version: link:../plugins/notifier-desktop
'@composio/ao-plugin-notifier-openclaw':
specifier: workspace:*
version: link:../plugins/notifier-openclaw
'@composio/ao-plugin-notifier-slack':
specifier: workspace:*
version: link:../plugins/notifier-slack
@ -185,6 +188,9 @@ importers:
'@composio/ao-plugin-notifier-desktop':
specifier: workspace:*
version: link:../plugins/notifier-desktop
'@composio/ao-plugin-notifier-openclaw':
specifier: workspace:*
version: link:../plugins/notifier-openclaw
'@composio/ao-plugin-notifier-slack':
specifier: workspace:*
version: link:../plugins/notifier-slack
@ -309,6 +315,22 @@ importers:
specifier: ^3.0.0
version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
packages/plugins/notifier-openclaw:
dependencies:
'@composio/ao-core':
specifier: workspace:*
version: link:../../core
devDependencies:
'@types/node':
specifier: ^25.2.3
version: 25.2.3
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
packages/plugins/notifier-slack:
dependencies:
'@composio/ao-core':