feat(hooks): add agentId to webhook mappings and /hooks/agent endpoint

Adds optional agentId field to webhook mappings and the direct /hooks/agent
endpoint, enabling multi-agent webhook routing.

Changes:
- Add agentId to HookMappingSchema (zod) and HookMappingConfig type
- Add agentId to HookMappingResolved, HookAction, and HookAgentPayload
- Pass agentId through mapping resolution, dispatcher, and HTTP handler
- Add superRefine validation: agentId must exist in agents.list
- Update docs with agentId parameter and usage examples
- Add unit tests for agentId in mappings and config validation

Use cases:
- Route incoming emails to a dedicated email-handler agent
- Send GitHub webhooks to a CI/CD agent with repo access
- Separate support inbox to agent with different persona

Closes #3432
This commit is contained in:
Walter Forkel 2026-01-28 17:26:58 +01:00
parent 01e0d3a320
commit c96577ec21
10 changed files with 220 additions and 13 deletions

View File

@ -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:<uuid>`. 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.

View File

@ -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);
});
});

View File

@ -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;

View File

@ -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(),

View File

@ -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).`,
});
}
}
});

View File

@ -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();
}
});
});

View File

@ -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,

View File

@ -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,

View File

@ -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 ?? "",

View File

@ -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,