diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md index 81565bd41..bba555f0f 100644 --- a/docs/automation/webhook.md +++ b/docs/automation/webhook.md @@ -55,6 +55,7 @@ Payload: { "message": "Run this", "name": "Email", + "agentId": "email-handler", "sessionKey": "hook:email:msg-123", "wakeMode": "now", "deliver": true, @@ -69,6 +70,7 @@ Payload: - `message` **required** (string): The prompt or message for the agent to process. - `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries. - `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:`. Using a consistent key allows for a multi-turn conversation within the hook context. +- `agentId` optional (string): Target agent to run this hook. Must exist in `agents.list`. Defaults to the default agent. Useful for routing webhooks to specialized agents with their own tools, workspace, and persona. - `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check. - `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped. - `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`. @@ -94,6 +96,7 @@ Mapping options (summary): - `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic. - Use `match.source` to keep a generic ingest endpoint (payload-driven routing). - TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime. +- Set `agentId` to route the hook to a specific agent (must exist in `agents.list`). - Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface (`channel` defaults to `last` and falls back to WhatsApp). - `allowUnsafeExternalContent: true` disables the external content safety wrapper for that hook @@ -145,6 +148,30 @@ curl -X POST http://127.0.0.1:18789/hooks/gmail \ -d '{"source":"gmail","messages":[{"from":"Ada","subject":"Hello","snippet":"Hi"}]}' ``` +### Route to a specific agent + +Use `agentId` to route webhooks to a dedicated agent in multi-agent setups: + +```json5 +{ + hooks: { + mappings: [ + { + match: { path: "/email" }, + action: "agent", + agentId: "email-handler", // Must exist in agents.list + messageTemplate: "New email from {{from}}: {{subject}}", + deliver: true, + channel: "telegram", + to: "123456789" + } + ] + } +} +``` + +This lets you have specialized agents with their own tools, workspace, and persona for different webhook sources (e.g., email processing, GitHub webhooks, support inbox). + ## Security - Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy. diff --git a/src/config/config.hooks-mapping-agentid.test.ts b/src/config/config.hooks-mapping-agentid.test.ts new file mode 100644 index 000000000..a19b1863a --- /dev/null +++ b/src/config/config.hooks-mapping-agentid.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; + +describe("hooks.mappings agentId validation", () => { + it("accepts valid agentId in hook mapping", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + agents: { + list: [{ id: "email-handler" }, { id: "default" }], + }, + hooks: { + enabled: true, + token: "test-token", + mappings: [ + { + id: "email-hook", + match: { path: "email" }, + action: "agent", + agentId: "email-handler", + messageTemplate: "New email", + }, + ], + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects unknown agentId in hook mapping", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + agents: { + list: [{ id: "default" }], + }, + hooks: { + enabled: true, + token: "test-token", + mappings: [ + { + id: "email-hook", + match: { path: "email" }, + action: "agent", + agentId: "nonexistent-agent", + messageTemplate: "New email", + }, + ], + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error).toContain("nonexistent-agent"); + expect(res.error).toContain("not in agents.list"); + } + }); + + it("accepts hook mapping without agentId (uses default)", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + agents: { + list: [{ id: "default" }], + }, + hooks: { + enabled: true, + token: "test-token", + mappings: [ + { + id: "email-hook", + match: { path: "email" }, + action: "agent", + messageTemplate: "New email", + }, + ], + }, + }); + expect(res.ok).toBe(true); + }); + + it("skips agentId validation when agents.list is empty", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + hooks: { + enabled: true, + token: "test-token", + mappings: [ + { + id: "email-hook", + match: { path: "email" }, + action: "agent", + agentId: "any-agent", + messageTemplate: "New email", + }, + ], + }, + }); + expect(res.ok).toBe(true); + }); +}); diff --git a/src/config/types.hooks.ts b/src/config/types.hooks.ts index 7ca74605a..b46aa900c 100644 --- a/src/config/types.hooks.ts +++ b/src/config/types.hooks.ts @@ -12,6 +12,8 @@ export type HookMappingConfig = { id?: string; match?: HookMappingMatch; action?: "wake" | "agent"; + /** Target agent for action: "agent". Must exist in agents.list. */ + agentId?: string; wakeMode?: "now" | "next-heartbeat"; name?: string; sessionKey?: string; diff --git a/src/config/zod-schema.hooks.ts b/src/config/zod-schema.hooks.ts index 35e74f7af..2cec47cc2 100644 --- a/src/config/zod-schema.hooks.ts +++ b/src/config/zod-schema.hooks.ts @@ -10,6 +10,7 @@ export const HookMappingSchema = z }) .optional(), action: z.union([z.literal("wake"), z.literal("agent")]).optional(), + agentId: z.string().optional(), wakeMode: z.union([z.literal("now"), z.literal("next-heartbeat")]).optional(), name: z.string().optional(), sessionKey: z.string().optional(), diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index ce4115517..979007d30 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -535,20 +535,34 @@ export const MoltbotSchema = z const agentIds = new Set(agents.map((agent) => agent.id)); const broadcast = cfg.broadcast; - if (!broadcast) return; - - for (const [peerId, ids] of Object.entries(broadcast)) { - if (peerId === "strategy") continue; - if (!Array.isArray(ids)) continue; - for (let idx = 0; idx < ids.length; idx += 1) { - const agentId = ids[idx]; - if (!agentIds.has(agentId)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["broadcast", peerId, idx], - message: `Unknown agent id "${agentId}" (not in agents.list).`, - }); + if (broadcast) { + for (const [peerId, ids] of Object.entries(broadcast)) { + if (peerId === "strategy") continue; + if (!Array.isArray(ids)) continue; + for (let idx = 0; idx < ids.length; idx += 1) { + const agentId = ids[idx]; + if (!agentIds.has(agentId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["broadcast", peerId, idx], + message: `Unknown agent id "${agentId}" (not in agents.list).`, + }); + } } } } + + const hookMappings = cfg.hooks?.mappings ?? []; + for (let idx = 0; idx < hookMappings.length; idx += 1) { + const mapping = hookMappings[idx]; + if (!mapping) continue; + const agentId = mapping.agentId?.trim(); + if (agentId && !agentIds.has(agentId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["hooks", "mappings", idx, "agentId"], + message: `Unknown agent id "${agentId}" (not in agents.list).`, + }); + } + } }); diff --git a/src/gateway/hooks-mapping.test.ts b/src/gateway/hooks-mapping.test.ts index 8900ffd07..b0dbd7af1 100644 --- a/src/gateway/hooks-mapping.test.ts +++ b/src/gateway/hooks-mapping.test.ts @@ -165,4 +165,52 @@ describe("hooks mapping", () => { }); expect(result?.ok).toBe(false); }); + + it("passes agentId from mapping to action", async () => { + const mappings = resolveHookMappings({ + mappings: [ + { + id: "email-hook", + match: { path: "email" }, + action: "agent", + agentId: "email-handler", + messageTemplate: "New email: {{subject}}", + }, + ], + }); + const result = await applyHookMappings(mappings, { + payload: { subject: "Test" }, + headers: {}, + url: new URL("http://127.0.0.1:18789/hooks/email"), + path: "email", + }); + expect(result?.ok).toBe(true); + if (result?.ok && result.action?.kind === "agent") { + expect(result.action.agentId).toBe("email-handler"); + expect(result.action.message).toBe("New email: Test"); + } + }); + + it("omits agentId when not specified", async () => { + const mappings = resolveHookMappings({ + mappings: [ + { + id: "default-hook", + match: { path: "default" }, + action: "agent", + messageTemplate: "Message: {{text}}", + }, + ], + }); + const result = await applyHookMappings(mappings, { + payload: { text: "Hello" }, + headers: {}, + url: new URL("http://127.0.0.1:18789/hooks/default"), + path: "default", + }); + expect(result?.ok).toBe(true); + if (result?.ok && result.action?.kind === "agent") { + expect(result.action.agentId).toBeUndefined(); + } + }); }); diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 2ebf9b136..2719faae1 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -9,6 +9,7 @@ export type HookMappingResolved = { matchPath?: string; matchSource?: string; action: "wake" | "agent"; + agentId?: string; wakeMode?: "now" | "next-heartbeat"; name?: string; sessionKey?: string; @@ -45,6 +46,7 @@ export type HookAction = | { kind: "agent"; message: string; + agentId?: string; name?: string; wakeMode: "now" | "next-heartbeat"; sessionKey?: string; @@ -84,6 +86,7 @@ type HookTransformResult = Partial<{ text: string; mode: "now" | "next-heartbeat"; message: string; + agentId: string; wakeMode: "now" | "next-heartbeat"; name: string; sessionKey: string; @@ -166,6 +169,7 @@ function normalizeHookMapping( const matchPath = normalizeMatchPath(mapping.match?.path); const matchSource = mapping.match?.source?.trim(); const action = mapping.action ?? "agent"; + const agentId = mapping.agentId?.trim() || undefined; const wakeMode = mapping.wakeMode ?? "now"; const transform = mapping.transform ? { @@ -179,6 +183,7 @@ function normalizeHookMapping( matchPath, matchSource, action, + agentId, wakeMode, name: mapping.name, sessionKey: mapping.sessionKey, @@ -227,6 +232,7 @@ function buildActionFromMapping( action: { kind: "agent", message, + agentId: mapping.agentId, name: renderOptional(mapping.name, ctx), wakeMode: mapping.wakeMode ?? "now", sessionKey: renderOptional(mapping.sessionKey, ctx), @@ -264,6 +270,7 @@ function mergeAction( return validateAction({ kind: "agent", message, + agentId: override.agentId ?? baseAgent?.agentId, wakeMode, name: override.name ?? baseAgent?.name, sessionKey: override.sessionKey ?? baseAgent?.sessionKey, diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index 1fc6d52f4..eee44b389 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -128,6 +128,7 @@ export function normalizeWakePayload( export type HookAgentPayload = { message: string; + agentId?: string; name: string; wakeMode: "now" | "next-heartbeat"; sessionKey: string; @@ -169,6 +170,9 @@ export function normalizeAgentPayload( | { ok: false; error: string } { const message = typeof payload.message === "string" ? payload.message.trim() : ""; if (!message) return { ok: false, error: "message required" }; + const agentIdRaw = payload.agentId; + const agentId = + typeof agentIdRaw === "string" && agentIdRaw.trim() ? agentIdRaw.trim() : undefined; const nameRaw = payload.name; const name = typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : "Hook"; const wakeMode = payload.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now"; @@ -200,6 +204,7 @@ export function normalizeAgentPayload( ok: true, value: { message, + agentId, name, wakeMode, sessionKey, diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index f08dc811c..45183b735 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -37,6 +37,7 @@ type HookDispatchers = { dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void; dispatchAgentHook: (value: { message: string; + agentId?: string; name: string; wakeMode: "now" | "next-heartbeat"; sessionKey: string; @@ -172,6 +173,7 @@ export function createHooksRequestHandler( } const runId = dispatchAgentHook({ message: mapped.action.message, + agentId: mapped.action.agentId, name: mapped.action.name ?? "Hook", wakeMode: mapped.action.wakeMode, sessionKey: mapped.action.sessionKey ?? "", diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index 18d46368f..d9b02f7ae 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -32,6 +32,7 @@ export function createGatewayHooksRequestHandler(params: { const dispatchAgentHook = (value: { message: string; + agentId?: string; name: string; wakeMode: "now" | "next-heartbeat"; sessionKey: string; @@ -49,6 +50,7 @@ export function createGatewayHooksRequestHandler(params: { const now = Date.now(); const job: CronJob = { id: jobId, + agentId: value.agentId, name: value.name, enabled: true, createdAtMs: now,