feat: add gateway webhooks

This commit is contained in:
Peter Steinberger
2025-12-24 14:32:55 +00:00
parent aa62ac4042
commit 1ed5ca3fde
8 changed files with 593 additions and 1 deletions

View File

@@ -6,6 +6,7 @@
- First-class Clawdis tools (browser, canvas, nodes, cron) replace the old `clawdis-*` skills; tool schemas are now injected directly into the agent runtime.
- Custom model providers: `models.providers` merges into `~/.clawdis/agent/models.json` (merge/replace modes) for LiteLLM, local OpenAI-compatible servers, Anthropic proxies, etc.
- Group chat activation modes: per-group `/activation mention|always` command with status visibility.
- Gateway webhooks: external `wake` and isolated `agent` hooks with dedicated token auth.
### Breaking
- Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read.

View File

@@ -311,6 +311,35 @@ Auth and Tailscale:
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
### `hooks` (Gateway webhooks)
Enable a simple HTTP webhook surface on the Gateway HTTP server.
Defaults:
- enabled: `false`
- path: `/hooks`
```json5
{
hooks: {
enabled: true,
token: "shared-secret",
path: "/hooks"
}
}
```
Requests must include the hook token:
- `Authorization: Bearer <token>` **or**
- `x-clawdis-token: <token>` **or**
- `?token=<token>`
Endpoints:
- `POST /hooks/wake``{ text, mode?: "now"|"next-heartbeat" }`
- `POST /hooks/agent``{ message, name?, sessionKey?, wakeMode?, deliver?, channel?, to?, thinking?, timeoutSeconds? }`
`/hooks/agent` always posts a summary into the main session (and can optionally trigger an immediate heartbeat via `wakeMode: "now"`).
### `canvasHost` (LAN/tailnet Canvas file server + live reload)
The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can simply `canvas.navigate` to it.

View File

@@ -21,6 +21,11 @@ The UI talks directly to the Gateway WS and supports:
- Config (`config.get`, `config.set`) for `~/.clawdis/clawdis.json`
- Debug (status/health/models snapshots + manual calls)
## Webhooks
When `hooks.enabled=true`, the Gateway also exposes a small webhook surface on the same HTTP server.
See `docs/configuration.md``hooks` for auth + payloads.
## Config (default-on)
The Control UI is **enabled by default** when assets are present (`dist/control-ui`).

111
docs/webhook.md Normal file
View File

@@ -0,0 +1,111 @@
---
summary: "Webhook ingress for wake and isolated agent runs"
read_when:
- Adding or changing webhook endpoints
- Wiring external systems into Clawdis
---
# Webhooks
Gateway can expose a small HTTP webhook surface for external triggers.
## Enable
```json5
{
hooks: {
enabled: true,
token: "shared-secret",
path: "/hooks"
}
}
```
Notes:
- `hooks.token` is required when `hooks.enabled=true`.
- `hooks.path` defaults to `/hooks`.
## Auth
Every request must include the hook token:
- `Authorization: Bearer <token>`
- or `x-clawdis-token: <token>`
- or `?token=<token>`
## Endpoints
### `POST /hooks/wake`
Payload:
```json
{ "text": "System line", "mode": "now" }
```
- `text` required (string)
- `mode` optional: `now` | `next-heartbeat` (default `now`)
Effect:
- Enqueues a system event for the **main** session
- If `mode=now`, triggers an immediate heartbeat
### `POST /hooks/agent`
Payload:
```json
{
"message": "Run this",
"name": "Email",
"sessionKey": "hook:email:msg-123",
"wakeMode": "now",
"deliver": false,
"channel": "last",
"to": "+15551234567",
"thinking": "low",
"timeoutSeconds": 120
}
```
- `message` required (string)
- `name` optional (used in the summary prefix)
- `sessionKey` optional (default random `hook:<uuid>`)
- `wakeMode` optional: `now` | `next-heartbeat` (default `now`)
- `deliver` optional (default `false`)
- `channel` optional: `last` | `whatsapp` | `telegram`
- `to` optional (channel-specific target)
- `thinking` optional (override)
- `timeoutSeconds` optional
Effect:
- Runs an **isolated** agent turn (own session key)
- Always posts a summary into the **main** session
- If `wakeMode=now`, triggers an immediate heartbeat
## Responses
- `200` for `/hooks/wake`
- `202` for `/hooks/agent` (async run started)
- `401` on auth failure
- `400` on invalid payload
- `413` on oversized payloads
## Examples
```bash
curl -X POST http://127.0.0.1:18789/hooks/wake \
-H 'Authorization: Bearer SECRET' \
-H 'Content-Type: application/json' \
-d '{"text":"New email received","mode":"now"}'
```
```bash
curl -X POST http://127.0.0.1:18789/hooks/agent \
-H 'x-clawdis-token: SECRET' \
-H 'Content-Type: application/json' \
-d '{"message":"Summarize inbox","name":"Email","wakeMode":"next-heartbeat"}'
```
## Security
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
- Use a dedicated hook token; do not reuse gateway auth tokens.
- Avoid including sensitive raw payloads in webhook logs.

View File

@@ -507,6 +507,30 @@ export function registerGatewayCli(program: Command) {
}),
);
gatewayCallOpts(
gateway
.command("wake")
.description("Enqueue a system event and optionally trigger a heartbeat")
.requiredOption("--text <text>", "System event text")
.option(
"--mode <mode>",
"Wake mode (now|next-heartbeat)",
"next-heartbeat",
)
.action(async (opts) => {
try {
const result = await callGatewayCli("wake", opts, {
mode: opts.mode,
text: opts.text,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
}),
);
gatewayCallOpts(
gateway
.command("send")

View File

@@ -62,6 +62,12 @@ export type CronConfig = {
maxConcurrentRuns?: number;
};
export type HooksConfig = {
enabled?: boolean;
path?: string;
token?: string;
};
export type TelegramConfig = {
botToken?: string;
requireMention?: boolean;
@@ -271,6 +277,7 @@ export type ClawdisConfig = {
web?: WebConfig;
telegram?: TelegramConfig;
cron?: CronConfig;
hooks?: HooksConfig;
bridge?: BridgeConfig;
discovery?: DiscoveryConfig;
canvasHost?: CanvasHostConfig;
@@ -461,6 +468,13 @@ const ClawdisSchema = z.object({
maxConcurrentRuns: z.number().int().positive().optional(),
})
.optional(),
hooks: z
.object({
enabled: z.boolean().optional(),
path: z.string().optional(),
token: z.string().optional(),
})
.optional(),
web: z
.object({
heartbeatSeconds: z.number().int().positive().optional(),

View File

@@ -10,6 +10,7 @@ import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
import { rawDataToString } from "../infra/ws.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import {
@@ -74,6 +75,9 @@ const piSdkMock = vi.hoisted(() => ({
contextWindow?: number;
}>,
}));
const cronIsolatedRun = vi.hoisted(() =>
vi.fn(async () => ({ status: "ok", summary: "ok" })),
);
vi.mock("@mariozechner/pi-coding-agent", async () => {
const actual = await vi.importActual<
@@ -101,6 +105,9 @@ vi.mock("../infra/bridge/server.js", () => ({
};
}),
}));
vi.mock("../cron/isolated-agent.js", () => ({
runCronIsolatedAgentTurn: (...args: unknown[]) => cronIsolatedRun(...args),
}));
vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4: () => testTailnetIPv4.value,
pickPrimaryTailnetIPv6: () => undefined,
@@ -112,6 +119,7 @@ let testCronStorePath: string | undefined;
let testCronEnabled: boolean | undefined = false;
let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
let testGatewayAuth: Record<string, unknown> | undefined;
let testHooksConfig: Record<string, unknown> | undefined;
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
vi.mock("../config/sessions.js", async () => {
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
@@ -197,6 +205,7 @@ vi.mock("../config/config.js", () => {
if (testGatewayAuth) gateway.auth = testGatewayAuth;
return Object.keys(gateway).length > 0 ? gateway : undefined;
})(),
hooks: testHooksConfig,
cron: (() => {
const cron: Record<string, unknown> = {};
if (typeof testCronEnabled === "boolean")
@@ -251,6 +260,9 @@ beforeEach(async () => {
testTailnetIPv4.value = undefined;
testGatewayBind = undefined;
testGatewayAuth = undefined;
testHooksConfig = undefined;
cronIsolatedRun.mockClear();
drainSystemEvents();
__resetModelCatalogCacheForTest();
piSdkMock.enabled = false;
piSdkMock.discoverCalls = 0;
@@ -413,6 +425,16 @@ async function rpcReq<T = unknown>(
}>(ws, (o) => o.type === "res" && o.id === id);
}
async function waitForSystemEvent(timeoutMs = 2000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const events = peekSystemEvents();
if (events.length > 0) return events;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error("timeout waiting for system event");
}
describe("gateway server", () => {
test("voicewake.get returns defaults and voicewake.set broadcasts", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
@@ -3598,4 +3620,95 @@ describe("gateway server", () => {
probe.close((err) => (err ? reject(err) : resolve())),
);
});
test("hooks wake requires auth", async () => {
testHooksConfig = { enabled: true, token: "hook-secret" };
const port = await getFreePort();
const server = await startGatewayServer(port);
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Ping" }),
});
expect(res.status).toBe(401);
await server.close();
});
test("hooks wake enqueues system event", async () => {
testHooksConfig = { enabled: true, token: "hook-secret" };
const port = await getFreePort();
const server = await startGatewayServer(port);
const res = await fetch(`http://127.0.0.1:${port}/hooks/wake`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer hook-secret",
},
body: JSON.stringify({ text: "Ping", mode: "next-heartbeat" }),
});
expect(res.status).toBe(200);
const events = await waitForSystemEvent();
expect(events.some((e) => e.includes("Ping"))).toBe(true);
drainSystemEvents();
await server.close();
});
test("hooks agent posts summary to main", async () => {
testHooksConfig = { enabled: true, token: "hook-secret" };
cronIsolatedRun.mockResolvedValueOnce({
status: "ok",
summary: "done",
});
const port = await getFreePort();
const server = await startGatewayServer(port);
const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer hook-secret",
},
body: JSON.stringify({ message: "Do it", name: "Email" }),
});
expect(res.status).toBe(202);
const events = await waitForSystemEvent();
expect(events.some((e) => e.includes("Hook Email: done"))).toBe(true);
drainSystemEvents();
await server.close();
});
test("hooks wake accepts query token", async () => {
testHooksConfig = { enabled: true, token: "hook-secret" };
const port = await getFreePort();
const server = await startGatewayServer(port);
const res = await fetch(
`http://127.0.0.1:${port}/hooks/wake?token=hook-secret`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Query auth" }),
},
);
expect(res.status).toBe(200);
const events = await waitForSystemEvent();
expect(events.some((e) => e.includes("Query auth"))).toBe(true);
drainSystemEvents();
await server.close();
});
test("hooks agent rejects invalid channel", async () => {
testHooksConfig = { enabled: true, token: "hook-secret" };
const port = await getFreePort();
const server = await startGatewayServer(port);
const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer hook-secret",
},
body: JSON.stringify({ message: "Nope", channel: "sms" }),
});
expect(res.status).toBe(400);
expect(peekSystemEvents().length).toBe(0);
await server.close();
});
});

View File

@@ -65,7 +65,7 @@ import {
} from "../cron/run-log.js";
import { CronService } from "../cron/service.js";
import { resolveCronStorePath } from "../cron/store.js";
import type { CronJobCreate, CronJobPatch } from "../cron/types.js";
import type { CronJob, CronJobCreate, CronJobPatch } from "../cron/types.js";
import { isVerbose } from "../globals.js";
import { onAgentEvent } from "../infra/agent-events.js";
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
@@ -146,6 +146,109 @@ import { handleControlUiHttpRequest } from "./control-ui.js";
ensureClawdisCliOnPath();
const DEFAULT_HOOKS_PATH = "/hooks";
const DEFAULT_HOOKS_MAX_BODY_BYTES = 256 * 1024;
type HooksConfigResolved = {
basePath: string;
token: string;
maxBodyBytes: number;
};
function resolveHooksConfig(cfg: ClawdisConfig): HooksConfigResolved | null {
if (cfg.hooks?.enabled !== true) return null;
const token = cfg.hooks?.token?.trim();
if (!token) {
throw new Error("hooks.enabled requires hooks.token");
}
const rawPath = cfg.hooks?.path?.trim() || DEFAULT_HOOKS_PATH;
const withSlash = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
const trimmed =
withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
if (trimmed === "/") {
throw new Error("hooks.path may not be '/'");
}
return {
basePath: trimmed,
token,
maxBodyBytes: DEFAULT_HOOKS_MAX_BODY_BYTES,
};
}
function extractHookToken(
req: IncomingMessage,
url: URL,
): string | undefined {
const auth =
typeof req.headers.authorization === "string"
? req.headers.authorization.trim()
: "";
if (auth.toLowerCase().startsWith("bearer ")) {
const token = auth.slice(7).trim();
if (token) return token;
}
const headerToken =
typeof req.headers["x-clawdis-token"] === "string"
? req.headers["x-clawdis-token"].trim()
: "";
if (headerToken) return headerToken;
const queryToken = url.searchParams.get("token");
if (queryToken) return queryToken.trim();
return undefined;
}
async function readJsonBody(
req: IncomingMessage,
maxBytes: number,
): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> {
return await new Promise((resolve) => {
let done = false;
let total = 0;
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => {
if (done) return;
total += chunk.length;
if (total > maxBytes) {
done = true;
resolve({ ok: false, error: "payload too large" });
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (done) return;
done = true;
const raw = Buffer.concat(chunks).toString("utf-8").trim();
if (!raw) {
resolve({ ok: true, value: {} });
return;
}
try {
const parsed = JSON.parse(raw) as unknown;
resolve({ ok: true, value: parsed });
} catch (err) {
resolve({ ok: false, error: String(err) });
}
});
req.on("error", (err) => {
if (done) return;
done = true;
resolve({ ok: false, error: String(err) });
});
});
}
function sendJson(
res: import("node:http").ServerResponse,
status: number,
body: unknown,
) {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
const log = createSubsystemLogger("gateway");
const logCanvas = log.child("canvas");
const logBridge = log.child("bridge");
@@ -155,6 +258,7 @@ const logProviders = log.child("providers");
const logBrowser = log.child("browser");
const logHealth = log.child("health");
const logCron = log.child("cron");
const logHooks = log.child("hooks");
const logWsControl = log.child("ws");
const logWhatsApp = logProviders.child("whatsapp");
const logTelegram = logProviders.child("telegram");
@@ -1174,6 +1278,7 @@ export async function startGatewayServer(
password,
allowTailscale,
};
const hooksConfig = resolveHooksConfig(cfgAtStart);
const canvasHostEnabled =
process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" &&
cfgAtStart.canvasHost?.enabled !== false;
@@ -1194,6 +1299,7 @@ export async function startGatewayServer(
);
}
let canvasHost: CanvasHostHandler | null = null;
let canvasHostServer: CanvasHostServer | null = null;
if (canvasHostEnabled) {
@@ -1215,11 +1321,200 @@ export async function startGatewayServer(
}
}
const handleHooksRequest = async (
req: IncomingMessage,
res: import("node:http").ServerResponse,
): Promise<boolean> => {
if (!hooksConfig) return false;
const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`);
const basePath = hooksConfig.basePath;
if (
url.pathname !== basePath &&
!url.pathname.startsWith(`${basePath}/`)
) {
return false;
}
const token = extractHookToken(req, url);
if (!token || token !== hooksConfig.token) {
res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Unauthorized");
return true;
}
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Method Not Allowed");
return true;
}
const subPath = url.pathname
.slice(basePath.length)
.replace(/^\/+/, "");
if (!subPath) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
return true;
}
const body = await readJsonBody(req, hooksConfig.maxBodyBytes);
if (!body.ok) {
const status = body.error === "payload too large" ? 413 : 400;
sendJson(res, status, { ok: false, error: body.error });
return true;
}
const payload =
typeof body.value === "object" && body.value !== null ? body.value : {};
if (subPath === "wake") {
const text =
typeof (payload as { text?: unknown }).text === "string"
? (payload as { text?: string }).text.trim()
: "";
if (!text) {
sendJson(res, 400, { ok: false, error: "text required" });
return true;
}
const modeRaw = (payload as { mode?: unknown }).mode;
const mode =
modeRaw === "next-heartbeat" ? "next-heartbeat" : "now";
enqueueSystemEvent(text);
if (mode === "now") {
requestReplyHeartbeatNow({ reason: "hook:wake" });
}
sendJson(res, 200, { ok: true, mode });
return true;
}
if (subPath === "agent") {
const message =
typeof (payload as { message?: unknown }).message === "string"
? (payload as { message?: string }).message.trim()
: "";
if (!message) {
sendJson(res, 400, { ok: false, error: "message required" });
return true;
}
const nameRaw = (payload as { name?: unknown }).name;
const name =
typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : "Hook";
const wakeModeRaw = (payload as { wakeMode?: unknown }).wakeMode;
const wakeMode =
wakeModeRaw === "next-heartbeat" ? "next-heartbeat" : "now";
const sessionKeyRaw = (payload as { sessionKey?: unknown }).sessionKey;
const sessionKey =
typeof sessionKeyRaw === "string" && sessionKeyRaw.trim()
? sessionKeyRaw.trim()
: `hook:${randomUUID()}`;
const channelRaw = (payload as { channel?: unknown }).channel;
const channel =
channelRaw === "whatsapp" ||
channelRaw === "telegram" ||
channelRaw === "last"
? channelRaw
: channelRaw === undefined
? undefined
: null;
if (channel === null) {
sendJson(res, 400, {
ok: false,
error: "channel must be last|whatsapp|telegram",
});
return true;
}
const toRaw = (payload as { to?: unknown }).to;
const to =
typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
const deliver = (payload as { deliver?: unknown }).deliver === true;
const thinkingRaw = (payload as { thinking?: unknown }).thinking;
const thinking =
typeof thinkingRaw === "string" && thinkingRaw.trim()
? thinkingRaw.trim()
: undefined;
const timeoutRaw = (payload as { timeoutSeconds?: unknown }).timeoutSeconds;
const timeoutSeconds =
typeof timeoutRaw === "number" && Number.isFinite(timeoutRaw) && timeoutRaw > 0
? Math.floor(timeoutRaw)
: undefined;
const jobId = randomUUID();
const now = Date.now();
const job: CronJob = {
id: jobId,
name,
enabled: true,
createdAtMs: now,
updatedAtMs: now,
schedule: { kind: "at", atMs: now },
sessionTarget: "isolated",
wakeMode,
payload: {
kind: "agentTurn",
message,
thinking,
timeoutSeconds,
deliver,
channel: channel ?? "last",
to,
},
state: { nextRunAtMs: now },
};
const runId = randomUUID();
sendJson(res, 202, { ok: true, runId });
void (async () => {
try {
const cfg = loadConfig();
const result = await runCronIsolatedAgentTurn({
cfg,
deps,
job,
message,
sessionKey,
lane: "cron",
});
const summary =
result.summary?.trim() ||
result.error?.trim() ||
result.status;
const prefix =
result.status === "ok" ? `Hook ${name}` : `Hook ${name} (${result.status})`;
enqueueSystemEvent(`${prefix}: ${summary}`.trim());
if (wakeMode === "now") {
requestReplyHeartbeatNow({ reason: `hook:${jobId}` });
}
} catch (err) {
logHooks.warn({ err: String(err) }, "hook agent failed");
enqueueSystemEvent(`Hook ${name} (error): ${String(err)}`);
if (wakeMode === "now") {
requestReplyHeartbeatNow({ reason: `hook:${jobId}:error` });
}
}
})();
return true;
}
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
return true;
};
const httpServer: HttpServer = createHttpServer((req, res) => {
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
void (async () => {
if (await handleHooksRequest(req, res)) return;
if (canvasHost) {
if (await handleA2uiHttpRequest(req, res)) return;
if (await canvasHost.handleHttpRequest(req, res)) return;