diff --git a/packages/plugins/official/zapier-bridge/__tests__/index.test.ts b/packages/plugins/official/zapier-bridge/__tests__/index.test.ts
new file mode 100644
index 0000000..b055234
--- /dev/null
+++ b/packages/plugins/official/zapier-bridge/__tests__/index.test.ts
@@ -0,0 +1,992 @@
+///
+/**
+ * Zapier Bridge — Unit Tests
+ *
+ * Covers: DB key helpers, ID generators, purgeOldEvents, fanOutEvent,
+ * plugin manifest/settings, app:init (5 endpoints),
+ * POST /subscribe, DELETE /subscribe/:hookId, GET /subscribe,
+ * POST /action (all 3 action types + auth + guards),
+ * GET /events (filter, limit), conversation:end hook, user:register hook.
+ */
+import plugin, {
+ buildHookKey,
+ buildEventKey,
+ generateHookId,
+ generateEventId,
+ purgeOldEvents,
+ fanOutEvent,
+ SUPPORTED_EVENTS,
+ SUPPORTED_ACTIONS,
+ MAX_STORED_EVENTS,
+ HookRecord,
+ EventRecord,
+ ZapierActionPayload,
+} from "../src/index";
+import {
+ PluginContext,
+ PluginAPI,
+ PluginDatabaseAPI,
+ PluginEventBus,
+ EndpointDefinition,
+ EndpointRequest,
+ EndpointResponse,
+} from "@agentbase/plugin-sdk";
+
+// ── Mock factory ──────────────────────────────────────────────────────────────
+
+function createMockAPI(): PluginAPI & { _endpoints: EndpointDefinition[] } {
+ const store = new Map();
+ const _endpoints: EndpointDefinition[] = [];
+
+ const db: PluginDatabaseAPI = {
+ set: jest
+ .fn()
+ .mockImplementation(async (k: string, v: unknown) => store.set(k, v)),
+ get: jest
+ .fn()
+ .mockImplementation(async (k: string) => store.get(k) ?? null),
+ delete: jest.fn().mockImplementation(async (k: string) => {
+ const had = store.has(k);
+ store.delete(k);
+ return had;
+ }),
+ keys: jest
+ .fn()
+ .mockImplementation(async (prefix?: string) =>
+ [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)),
+ ),
+ find: jest.fn().mockResolvedValue([]),
+ count: jest.fn().mockResolvedValue(0),
+ };
+
+ const events: PluginEventBus = {
+ emit: jest.fn().mockResolvedValue(undefined),
+ on: jest.fn(),
+ off: jest.fn(),
+ };
+
+ const configStore = new Map([
+ ["enableTriggers", true],
+ ["enableActions", true],
+ ["allowedActions", "send_message,emit_event,update_context"],
+ ["actionSecret", ""],
+ ]);
+
+ return {
+ _endpoints,
+ getConfig: jest
+ .fn()
+ .mockImplementation((k: string) => configStore.get(k) ?? undefined),
+ setConfig: jest
+ .fn()
+ .mockImplementation(async (k: string, v: unknown) =>
+ configStore.set(k, v),
+ ),
+ makeRequest: jest.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({}),
+ text: jest.fn().mockResolvedValue(""),
+ }),
+ log: jest.fn(),
+ db,
+ events,
+ registerEndpoint: jest
+ .fn()
+ .mockImplementation((def: EndpointDefinition) => _endpoints.push(def)),
+ registerCronJob: jest.fn(),
+ registerWebhook: jest.fn(),
+ registerAdminPage: jest.fn(),
+ } as unknown as PluginAPI & { _endpoints: EndpointDefinition[] };
+}
+
+type MockAPI = ReturnType;
+type MockCtx = PluginContext & { api: MockAPI };
+
+function makeCtx(overrides: Partial = {}): MockCtx {
+ const api = createMockAPI();
+ return {
+ appId: "app-1",
+ userId: "user-1",
+ config: {},
+ api,
+ ...overrides,
+ } as MockCtx;
+}
+
+interface MockRes {
+ status: jest.Mock;
+ json: jest.Mock;
+ _status: number;
+ _body: unknown;
+}
+
+function makeRes(): MockRes {
+ const r: MockRes = {
+ _status: 200,
+ _body: undefined,
+ status: jest.fn(),
+ json: jest.fn(),
+ };
+ r.status.mockImplementation((code: number) => {
+ r._status = code;
+ return r;
+ });
+ r.json.mockImplementation((body: unknown) => {
+ r._body = body;
+ });
+ return r;
+}
+
+function makeReq(overrides: Partial = {}): EndpointRequest {
+ return {
+ method: "GET",
+ path: "/",
+ params: {},
+ query: {},
+ body: {},
+ headers: {},
+ ...overrides,
+ };
+}
+
+async function runInit(ctx: MockCtx): Promise {
+ const hook = plugin.definition.hooks?.["app:init"];
+ if (!hook) throw new Error("app:init hook not registered");
+ await hook(ctx);
+}
+
+function getEndpoint(
+ api: MockAPI,
+ method: string,
+ path: string,
+): EndpointDefinition {
+ const ep = api._endpoints.find((e) => e.method === method && e.path === path);
+ if (!ep) throw new Error(`Endpoint ${method} ${path} not found`);
+ return ep;
+}
+
+// ── DB Key Helpers ────────────────────────────────────────────────────────────
+
+describe("DB key helpers", () => {
+ it("buildHookKey", () => {
+ expect(buildHookKey("h_abc")).toBe("hook:h_abc");
+ });
+
+ it("buildEventKey", () => {
+ expect(buildEventKey("e_xyz")).toBe("event:e_xyz");
+ });
+});
+
+// ── ID generators ─────────────────────────────────────────────────────────────
+
+describe("ID generators", () => {
+ it("generateHookId starts with hook_", () => {
+ expect(generateHookId()).toMatch(/^hook_[a-f0-9]+$/);
+ });
+
+ it("generateHookId returns unique values", () => {
+ const ids = new Set(Array.from({ length: 20 }, generateHookId));
+ expect(ids.size).toBe(20);
+ });
+
+ it("generateEventId starts with evt_", () => {
+ expect(generateEventId()).toMatch(/^evt_[a-f0-9]+$/);
+ });
+
+ it("generateEventId returns unique values", () => {
+ const ids = new Set(Array.from({ length: 20 }, generateEventId));
+ expect(ids.size).toBe(20);
+ });
+});
+
+// ── purgeOldEvents ────────────────────────────────────────────────────────────
+
+describe("purgeOldEvents", () => {
+ it("does nothing when event count is at or below MAX_STORED_EVENTS", async () => {
+ const store = new Map();
+ for (let i = 0; i < 50; i++) {
+ store.set(`event:e${i}`, { eventId: `e${i}`, timestamp: i });
+ }
+ const dbDelete = jest.fn();
+ await purgeOldEvents(
+ async (k) => store.get(k) ?? null,
+ dbDelete,
+ async (prefix) =>
+ [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)),
+ );
+ expect(dbDelete).not.toHaveBeenCalled();
+ });
+
+ it("prunes oldest events when over MAX_STORED_EVENTS", async () => {
+ const store = new Map();
+ const total = MAX_STORED_EVENTS + 5;
+ for (let i = 0; i < total; i++) {
+ store.set(`event:e${i}`, { eventId: `e${i}`, timestamp: i });
+ }
+ const deleted: string[] = [];
+ await purgeOldEvents(
+ async (k) => store.get(k) ?? null,
+ async (k) => {
+ deleted.push(k);
+ return true;
+ },
+ async (prefix) =>
+ [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)),
+ );
+ // Should delete the 5 oldest (lowest timestamps: e0..e4)
+ expect(deleted).toHaveLength(5);
+ const deletedIds = deleted.map((k) => k.replace("event:", ""));
+ expect(deletedIds).toEqual(
+ expect.arrayContaining(["e0", "e1", "e2", "e3", "e4"]),
+ );
+ });
+});
+
+// ── fanOutEvent ───────────────────────────────────────────────────────────────
+
+describe("fanOutEvent", () => {
+ it("stores an EventRecord", async () => {
+ const ctx = makeCtx();
+ await fanOutEvent(ctx, "conversation.end", { some: "data" });
+ expect(ctx.api.db.set).toHaveBeenCalledWith(
+ expect.stringMatching(/^event:/),
+ expect.objectContaining({ type: "conversation.end" }),
+ );
+ });
+
+ it("POSTs to all subscribers of the matching event", async () => {
+ const ctx = makeCtx();
+ const hook1: HookRecord = {
+ hookId: "h1",
+ targetUrl: "https://a.com",
+ event: "conversation.end",
+ createdAt: 1,
+ };
+ const hook2: HookRecord = {
+ hookId: "h2",
+ targetUrl: "https://b.com",
+ event: "user.register",
+ createdAt: 2,
+ };
+ await ctx.api.db.set("hook:h1", hook1);
+ await ctx.api.db.set("hook:h2", hook2);
+
+ await fanOutEvent(ctx, "conversation.end", {});
+ await new Promise((r) => setTimeout(r, 20));
+
+ const makeReqCalls = (ctx.api.makeRequest as jest.Mock).mock.calls as [
+ string,
+ RequestInit,
+ ][];
+ const postedUrls = makeReqCalls.map(([url]) => url);
+ expect(postedUrls).toContain("https://a.com");
+ expect(postedUrls).not.toContain("https://b.com");
+ });
+
+ it("posts to 'custom' event subscribers for any event type", async () => {
+ const ctx = makeCtx();
+ const hook: HookRecord = {
+ hookId: "hc",
+ targetUrl: "https://c.com",
+ event: "custom",
+ createdAt: 1,
+ };
+ await ctx.api.db.set("hook:hc", hook);
+ await fanOutEvent(ctx, "conversation.end", {});
+ await new Promise((r) => setTimeout(r, 20));
+ const calls = (ctx.api.makeRequest as jest.Mock).mock.calls as [string][];
+ expect(calls.some(([url]) => url === "https://c.com")).toBe(true);
+ });
+
+ it("logs on delivery failure but does not throw", async () => {
+ const ctx = makeCtx();
+ const hook: HookRecord = {
+ hookId: "hfail",
+ targetUrl: "https://fail.com",
+ event: "user.register",
+ createdAt: 1,
+ };
+ await ctx.api.db.set("hook:hfail", hook);
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ });
+ await fanOutEvent(ctx, "user.register", {});
+ await new Promise((r) => setTimeout(r, 20));
+ expect(ctx.api.log).toHaveBeenCalledWith(
+ expect.stringContaining("failed"),
+ "warn",
+ );
+ });
+
+ it("logs on delivery exception but does not throw", async () => {
+ const ctx = makeCtx();
+ const hook: HookRecord = {
+ hookId: "herr",
+ targetUrl: "https://err.com",
+ event: "user.register",
+ createdAt: 1,
+ };
+ await ctx.api.db.set("hook:herr", hook);
+ (ctx.api.makeRequest as jest.Mock).mockRejectedValueOnce(
+ new Error("ECONNREFUSED"),
+ );
+ await fanOutEvent(ctx, "user.register", {});
+ await new Promise((r) => setTimeout(r, 20));
+ expect(ctx.api.log).toHaveBeenCalledWith(
+ expect.stringContaining("ECONNREFUSED"),
+ "error",
+ );
+ });
+});
+
+// ── Plugin manifest / settings ────────────────────────────────────────────────
+
+describe("plugin manifest / settings", () => {
+ it("name is zapier-bridge", () => {
+ expect(plugin.definition.name).toBe("zapier-bridge");
+ });
+
+ it("version is 1.0.0", () => {
+ expect(plugin.definition.version).toBe("1.0.0");
+ });
+
+ it("has required settings", () => {
+ const settings = plugin.definition.settings!;
+ expect(settings).toHaveProperty("enableTriggers");
+ expect(settings).toHaveProperty("enableActions");
+ expect(settings).toHaveProperty("allowedActions");
+ expect(settings).toHaveProperty("actionSecret");
+ });
+
+ it("actionSecret is encrypted", () => {
+ expect(plugin.definition.settings!["actionSecret"]?.["encrypted"]).toBe(
+ true,
+ );
+ });
+
+ it("enableTriggers default is true", () => {
+ expect(plugin.definition.settings!["enableTriggers"]?.default).toBe(true);
+ });
+});
+
+// ── app:init ──────────────────────────────────────────────────────────────────
+
+describe("app:init", () => {
+ it("registers exactly 5 endpoints", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ expect(ctx.api._endpoints).toHaveLength(5);
+ });
+
+ it("registers all expected endpoint paths", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const paths = ctx.api._endpoints.map((e) => `${e.method} ${e.path}`);
+ expect(paths).toContain("POST /subscribe");
+ expect(paths).toContain("DELETE /subscribe/:hookId");
+ expect(paths).toContain("GET /subscribe");
+ expect(paths).toContain("POST /action");
+ expect(paths).toContain("GET /events");
+ });
+});
+
+// ── POST /subscribe ───────────────────────────────────────────────────────────
+
+describe("POST /subscribe", () => {
+ it("returns 403 when enableTriggers is false", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) =>
+ k === "enableTriggers" ? false : undefined,
+ );
+ const ep = getEndpoint(ctx.api, "POST", "/subscribe");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({
+ body: { targetUrl: "https://z.com", event: "conversation.end" },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(403);
+ });
+
+ it("returns 400 when targetUrl is missing", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/subscribe");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ body: { event: "conversation.end" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ expect((res._body as { error: string }).error).toContain("targetUrl");
+ });
+
+ it("returns 400 when event is missing", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/subscribe");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ body: { targetUrl: "https://z.com" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ expect((res._body as { error: string }).error).toContain("event");
+ });
+
+ it("returns 400 for unsupported event type", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/subscribe");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ body: { targetUrl: "https://z.com", event: "unknown.event" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ expect((res._body as { error: string }).error).toContain(
+ "Unsupported event",
+ );
+ });
+
+ it("saves hook and returns 201 with id on success", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/subscribe");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({
+ body: {
+ targetUrl: "https://hooks.zapier.com/123",
+ event: "conversation.end",
+ },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(201);
+ const body = res._body as { id: string; event: string; targetUrl: string };
+ expect(body.event).toBe("conversation.end");
+ expect(body.id).toMatch(/^hook_/);
+ expect(ctx.api.db.set).toHaveBeenCalledWith(
+ expect.stringMatching(/^hook:/),
+ expect.objectContaining({ targetUrl: "https://hooks.zapier.com/123" }),
+ );
+ });
+
+ it("accepts 'custom' as a valid event type", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/subscribe");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ body: { targetUrl: "https://z.com/hook", event: "custom" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(201);
+ });
+
+ it("accepts all SUPPORTED_EVENTS", async () => {
+ for (const event of SUPPORTED_EVENTS) {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/subscribe");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ body: { targetUrl: "https://z.com", event } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(201);
+ }
+ });
+});
+
+// ── DELETE /subscribe/:hookId ─────────────────────────────────────────────────
+
+describe("DELETE /subscribe/:hookId", () => {
+ it("returns 400 when hookId param is missing", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "DELETE", "/subscribe/:hookId");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ params: {} }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ });
+
+ it("returns 404 when hook does not exist", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "DELETE", "/subscribe/:hookId");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ params: { hookId: "nonexistent" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(404);
+ });
+
+ it("deletes hook and returns deleted:true", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const hook: HookRecord = {
+ hookId: "hook_del1",
+ targetUrl: "https://a.com",
+ event: "user.register",
+ createdAt: 1,
+ };
+ await ctx.api.db.set(buildHookKey("hook_del1"), hook);
+
+ const ep = getEndpoint(ctx.api, "DELETE", "/subscribe/:hookId");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ params: { hookId: "hook_del1" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(200);
+ expect((res._body as { deleted: boolean }).deleted).toBe(true);
+ expect(ctx.api.db.delete).toHaveBeenCalledWith(buildHookKey("hook_del1"));
+ });
+});
+
+// ── GET /subscribe ────────────────────────────────────────────────────────────
+
+describe("GET /subscribe", () => {
+ it("returns empty hooks list when none registered", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/subscribe");
+ const res = makeRes();
+ await ep.handler!(makeReq(), res as unknown as EndpointResponse);
+ expect(res._status).toBe(200);
+ expect((res._body as { hooks: unknown[] }).hooks).toEqual([]);
+ });
+
+ it("returns all registered hooks", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const h1: HookRecord = {
+ hookId: "h1",
+ targetUrl: "https://a.com",
+ event: "conversation.end",
+ createdAt: 1,
+ };
+ const h2: HookRecord = {
+ hookId: "h2",
+ targetUrl: "https://b.com",
+ event: "user.register",
+ createdAt: 2,
+ };
+ await ctx.api.db.set(buildHookKey("h1"), h1);
+ await ctx.api.db.set(buildHookKey("h2"), h2);
+
+ const ep = getEndpoint(ctx.api, "GET", "/subscribe");
+ const res = makeRes();
+ await ep.handler!(makeReq(), res as unknown as EndpointResponse);
+ expect((res._body as { hooks: HookRecord[] }).hooks).toHaveLength(2);
+ });
+});
+
+// ── POST /action ──────────────────────────────────────────────────────────────
+
+describe("POST /action", () => {
+ it("returns 403 when enableActions is false", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) =>
+ k === "enableActions" ? false : undefined,
+ );
+ const ep = getEndpoint(ctx.api, "POST", "/action");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ body: { action: "send_message", data: {} } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(403);
+ });
+
+ it("returns 401 when actionSecret is set and header is missing", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => {
+ if (k === "actionSecret") return "super_secret";
+ if (k === "enableActions") return true;
+ if (k === "allowedActions") return "send_message";
+ return undefined;
+ });
+ const ep = getEndpoint(ctx.api, "POST", "/action");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({
+ body: { action: "send_message", data: { message: "hi" } },
+ headers: {},
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(401);
+ });
+
+ it("returns 401 when actionSecret is set and header is wrong", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => {
+ if (k === "actionSecret") return "super_secret";
+ if (k === "enableActions") return true;
+ if (k === "allowedActions") return "send_message";
+ return undefined;
+ });
+ const ep = getEndpoint(ctx.api, "POST", "/action");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({
+ body: { action: "send_message", data: { message: "hi" } },
+ headers: { "x-zapier-secret": "wrong" },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(401);
+ });
+
+ it("passes through when correct secret provided", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => {
+ if (k === "actionSecret") return "super_secret";
+ if (k === "enableActions") return true;
+ if (k === "allowedActions") return "send_message";
+ return undefined;
+ });
+ const ep = getEndpoint(ctx.api, "POST", "/action");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({
+ body: { action: "send_message", data: { message: "hello" } },
+ headers: { "x-zapier-secret": "super_secret" },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(200);
+ });
+
+ it("returns 400 when action field is missing", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/action");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ body: { data: {} } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ expect((res._body as { error: string }).error).toContain("action");
+ });
+
+ it("returns 400 when data field is missing", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/action");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ body: { action: "send_message" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ });
+
+ it("returns 403 when action is not in allowedActions", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => {
+ if (k === "enableActions") return true;
+ if (k === "allowedActions") return "send_message";
+ if (k === "actionSecret") return "";
+ return undefined;
+ });
+ const ep = getEndpoint(ctx.api, "POST", "/action");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ body: { action: "emit_event", data: { eventName: "x" } } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(403);
+ expect((res._body as { error: string }).error).toContain("allowed actions");
+ });
+
+ describe("send_message action", () => {
+ it("returns 400 when message is missing", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/action");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ body: { action: "send_message", data: { userId: "U1" } } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ expect((res._body as { error: string }).error).toContain("message");
+ });
+
+ it("emits zapier:send_message and returns ok", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/action");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({
+ body: {
+ action: "send_message",
+ data: { message: "Hello AI", userId: "U1" },
+ },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(200);
+ expect(ctx.api.events.emit).toHaveBeenCalledWith(
+ "zapier:send_message",
+ expect.objectContaining({ message: "Hello AI", userId: "U1" }),
+ );
+ });
+ });
+
+ describe("emit_event action", () => {
+ it("returns 400 when eventName is missing", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/action");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ body: { action: "emit_event", data: { eventData: {} } } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ expect((res._body as { error: string }).error).toContain("eventName");
+ });
+
+ it("emits zapier: and returns ok with eventName", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/action");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({
+ body: {
+ action: "emit_event",
+ data: { eventName: "myEvent", eventData: { x: 1 } },
+ },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(200);
+ expect((res._body as { eventName: string }).eventName).toBe("myEvent");
+ expect(ctx.api.events.emit).toHaveBeenCalledWith(
+ "zapier:myEvent",
+ expect.anything(),
+ );
+ });
+ });
+
+ describe("update_context action", () => {
+ it("returns 400 when key is missing", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/action");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ body: { action: "update_context", data: { value: "v" } } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(400);
+ });
+
+ it("emits zapier:update_context and returns ok with key", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/action");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({
+ body: {
+ action: "update_context",
+ data: { key: "theme", value: "dark" },
+ },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(200);
+ expect((res._body as { key: string }).key).toBe("theme");
+ expect(ctx.api.events.emit).toHaveBeenCalledWith(
+ "zapier:update_context",
+ expect.objectContaining({ key: "theme", value: "dark" }),
+ );
+ });
+ });
+});
+
+// ── GET /events ───────────────────────────────────────────────────────────────
+
+describe("GET /events", () => {
+ it("returns empty events array when none stored", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/events");
+ const res = makeRes();
+ await ep.handler!(makeReq(), res as unknown as EndpointResponse);
+ expect(res._status).toBe(200);
+ expect((res._body as { events: unknown[] }).events).toEqual([]);
+ });
+
+ it("returns stored events sorted newest first", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const e1: EventRecord = {
+ eventId: "e1",
+ type: "conversation.end",
+ payload: {},
+ timestamp: 1000,
+ };
+ const e2: EventRecord = {
+ eventId: "e2",
+ type: "user.register",
+ payload: {},
+ timestamp: 2000,
+ };
+ await ctx.api.db.set(buildEventKey("e1"), e1);
+ await ctx.api.db.set(buildEventKey("e2"), e2);
+
+ const ep = getEndpoint(ctx.api, "GET", "/events");
+ const res = makeRes();
+ await ep.handler!(makeReq(), res as unknown as EndpointResponse);
+ const events = (res._body as { events: EventRecord[] }).events;
+ expect(events[0]!.eventId).toBe("e2"); // newest first
+ expect(events[1]!.eventId).toBe("e1");
+ });
+
+ it("filters by type when query.type is provided", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const e1: EventRecord = {
+ eventId: "e1",
+ type: "conversation.end",
+ payload: {},
+ timestamp: 1000,
+ };
+ const e2: EventRecord = {
+ eventId: "e2",
+ type: "user.register",
+ payload: {},
+ timestamp: 2000,
+ };
+ await ctx.api.db.set(buildEventKey("e1"), e1);
+ await ctx.api.db.set(buildEventKey("e2"), e2);
+
+ const ep = getEndpoint(ctx.api, "GET", "/events");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ query: { type: "user.register" } }),
+ res as unknown as EndpointResponse,
+ );
+ const events = (res._body as { events: EventRecord[] }).events;
+ expect(events).toHaveLength(1);
+ expect(events[0]!.type).toBe("user.register");
+ });
+
+ it("respects limit query parameter", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ for (let i = 0; i < 10; i++) {
+ await ctx.api.db.set(buildEventKey(`e${i}`), {
+ eventId: `e${i}`,
+ type: "conversation.end",
+ payload: {},
+ timestamp: i,
+ });
+ }
+ const ep = getEndpoint(ctx.api, "GET", "/events");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ query: { limit: "3" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect((res._body as { events: unknown[] }).events).toHaveLength(3);
+ });
+
+ it("caps limit at MAX_STORED_EVENTS", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ for (let i = 0; i < 10; i++) {
+ await ctx.api.db.set(buildEventKey(`e${i}`), {
+ eventId: `e${i}`,
+ type: "user.register",
+ payload: {},
+ timestamp: i,
+ });
+ }
+ const ep = getEndpoint(ctx.api, "GET", "/events");
+ const res = makeRes();
+ await ep.handler!(
+ makeReq({ query: { limit: String(MAX_STORED_EVENTS + 50) } }),
+ res as unknown as EndpointResponse,
+ );
+ const events = (res._body as { events: unknown[] }).events;
+ expect(events.length).toBeLessThanOrEqual(MAX_STORED_EVENTS);
+ });
+});
+
+// ── conversation:end hook ─────────────────────────────────────────────────────
+
+describe("conversation:end hook", () => {
+ it("does nothing when enableTriggers is false", async () => {
+ const ctx = makeCtx();
+ (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) =>
+ k === "enableTriggers" ? false : undefined,
+ );
+ const hook = plugin.definition.hooks?.["conversation:end"];
+ if (!hook) throw new Error("conversation:end hook not registered");
+ await hook(ctx);
+ expect(ctx.api.db.set).not.toHaveBeenCalled();
+ });
+
+ it("calls fanOutEvent with conversation.end", async () => {
+ const ctx = makeCtx();
+ (ctx.api.getConfig as jest.Mock).mockReturnValue(true);
+ const hook = plugin.definition.hooks?.["conversation:end"];
+ if (!hook) throw new Error("conversation:end hook not registered");
+ await hook(ctx);
+ expect(ctx.api.db.set).toHaveBeenCalledWith(
+ expect.stringMatching(/^event:/),
+ expect.objectContaining({ type: "conversation.end" }),
+ );
+ });
+});
+
+// ── user:register hook ────────────────────────────────────────────────────────
+
+describe("user:register hook", () => {
+ it("does nothing when enableTriggers is false", async () => {
+ const ctx = makeCtx();
+ (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) =>
+ k === "enableTriggers" ? false : undefined,
+ );
+ const hook = plugin.definition.hooks?.["user:register"];
+ if (!hook) throw new Error("user:register hook not registered");
+ await hook(ctx);
+ expect(ctx.api.db.set).not.toHaveBeenCalled();
+ });
+
+ it("calls fanOutEvent with user.register", async () => {
+ const ctx = makeCtx();
+ (ctx.api.getConfig as jest.Mock).mockReturnValue(true);
+ const hook = plugin.definition.hooks?.["user:register"];
+ if (!hook) throw new Error("user:register hook not registered");
+ await hook(ctx);
+ expect(ctx.api.db.set).toHaveBeenCalledWith(
+ expect.stringMatching(/^event:/),
+ expect.objectContaining({ type: "user.register" }),
+ );
+ });
+});
diff --git a/packages/plugins/official/zapier-bridge/__tests__/tsconfig.json b/packages/plugins/official/zapier-bridge/__tests__/tsconfig.json
new file mode 100644
index 0000000..a05feed
--- /dev/null
+++ b/packages/plugins/official/zapier-bridge/__tests__/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "../..",
+ "baseUrl": "..",
+ "lib": ["ES2022", "DOM"],
+ "types": ["jest", "node"],
+ "paths": {
+ "@agentbase/plugin-sdk": ["../../../sdk/src/index.ts"]
+ }
+ },
+ "include": ["../src/**/*", "./**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/plugins/official/zapier-bridge/manifest.json b/packages/plugins/official/zapier-bridge/manifest.json
new file mode 100644
index 0000000..2ca509a
--- /dev/null
+++ b/packages/plugins/official/zapier-bridge/manifest.json
@@ -0,0 +1,13 @@
+{
+ "name": "zapier-bridge",
+ "version": "1.0.0",
+ "description": "Expose Agentbase events as Zapier triggers and accept inbound Zapier actions",
+ "category": "integration",
+ "author": "Agentbase",
+ "license": "UNLICENSED",
+ "agentbase": {
+ "pluginSlug": "zapier-bridge",
+ "minPlatformVersion": "1.0.0"
+ },
+ "permissions": ["network:external", "db:readwrite"]
+}
diff --git a/packages/plugins/official/zapier-bridge/package.json b/packages/plugins/official/zapier-bridge/package.json
new file mode 100644
index 0000000..d0063e8
--- /dev/null
+++ b/packages/plugins/official/zapier-bridge/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@agentbase/plugin-zapier-bridge",
+ "version": "1.0.0",
+ "description": "Expose Agentbase events as Zapier triggers and accept inbound Zapier actions.",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "license": "GPL-3.0-or-later",
+ "scripts": {
+ "build": "tsc",
+ "test": "jest --passWithNoTests",
+ "test:cov": "jest --coverage --passWithNoTests"
+ },
+ "dependencies": {
+ "@agentbase/plugin-sdk": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/jest": "^29.5.0",
+ "@types/node": "^25.5.2",
+ "jest": "^29.7.0",
+ "ts-jest": "^29.2.0",
+ "typescript": "^5.7.0"
+ },
+ "jest": {
+ "preset": "ts-jest",
+ "testEnvironment": "node",
+ "testMatch": [
+ "**/__tests__/**/*.test.ts"
+ ],
+ "globals": {
+ "ts-jest": {
+ "tsconfig": "./tsconfig.test.json"
+ }
+ },
+ "coverageThreshold": {
+ "global": {
+ "lines": 80
+ }
+ }
+ }
+}
diff --git a/packages/plugins/official/zapier-bridge/src/index.ts b/packages/plugins/official/zapier-bridge/src/index.ts
new file mode 100644
index 0000000..1b0e511
--- /dev/null
+++ b/packages/plugins/official/zapier-bridge/src/index.ts
@@ -0,0 +1,488 @@
+/**
+ * Zapier Bridge
+ *
+ * Exposes Agentbase events as Zapier triggers using the REST Hooks standard
+ * (instant delivery) and accepts inbound Zapier actions.
+ *
+ * REST Hooks pattern:
+ * - Zapier POSTs to /subscribe when a user enables a Zap trigger.
+ * - Agentbase immediately POSTs to the registered targetUrl whenever the
+ * subscribed event fires.
+ * - Zapier DELETEs /subscribe/:hookId when the Zap is disabled.
+ * - /events provides a polling fallback for the Zap editor's sample data.
+ *
+ * Inbound actions (/action):
+ * - Zapier POSTs a JSON payload to /action. The `action` field selects the
+ * operation; the `data` field carries parameters.
+ * - Protected by an optional per-plugin `actionSecret` header.
+ *
+ * @package @agentbase/plugin-zapier-bridge
+ * @version 1.0.0
+ */
+import { createPlugin, PluginContext } from "@agentbase/plugin-sdk";
+import { randomUUID } from "crypto";
+
+// ── Constants ─────────────────────────────────────────────────────────────────
+
+/** Maximum number of events stored for the polling fallback endpoint. */
+export const MAX_STORED_EVENTS = 100;
+
+/** Event types that can be subscribed to as Zapier triggers. */
+export const SUPPORTED_EVENTS = [
+ "conversation.end",
+ "user.register",
+ "custom",
+] as const;
+
+export type SupportedEvent = (typeof SUPPORTED_EVENTS)[number];
+
+/** Actions that can be triggered inbound from Zapier. */
+export const SUPPORTED_ACTIONS = [
+ "send_message",
+ "emit_event",
+ "update_context",
+] as const;
+
+export type SupportedAction = (typeof SUPPORTED_ACTIONS)[number];
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export interface HookRecord {
+ hookId: string;
+ targetUrl: string;
+ event: string;
+ createdAt: number;
+}
+
+export interface EventRecord {
+ eventId: string;
+ type: string;
+ payload: unknown;
+ timestamp: number;
+}
+
+export interface ZapierActionPayload {
+ action: string;
+ data: Record;
+ zapId?: string;
+}
+
+// ── DB Key Helpers ────────────────────────────────────────────────────────────
+
+export function buildHookKey(hookId: string): string {
+ return `hook:${hookId}`;
+}
+
+export function buildEventKey(eventId: string): string {
+ return `event:${eventId}`;
+}
+
+// ── ID Generation ─────────────────────────────────────────────────────────────
+
+export function generateHookId(): string {
+ return `hook_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
+}
+
+export function generateEventId(): string {
+ return `evt_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
+}
+
+// ── Event Storage ─────────────────────────────────────────────────────────────
+
+/**
+ * Prune stored events to keep only the most recent MAX_STORED_EVENTS entries.
+ * Called after every new event is stored.
+ */
+export async function purgeOldEvents(
+ dbGet: (key: string) => Promise,
+ dbDelete: (key: string) => Promise,
+ dbKeys: (prefix?: string) => Promise,
+): Promise {
+ const keys = await dbKeys("event:");
+ if (keys.length <= MAX_STORED_EVENTS) return;
+
+ const records = (
+ await Promise.all(keys.map(async (k) => ({ key: k, rec: await dbGet(k) })))
+ ).filter((x): x is { key: string; rec: EventRecord } => x.rec !== null);
+
+ records.sort(
+ (a, b) =>
+ (a.rec as EventRecord).timestamp - (b.rec as EventRecord).timestamp,
+ );
+
+ const toDelete = records.slice(0, records.length - MAX_STORED_EVENTS);
+ await Promise.all(toDelete.map(({ key }) => dbDelete(key)));
+}
+
+/**
+ * Store an event for the polling fallback endpoint and fan out to all
+ * matching REST Hook subscribers.
+ *
+ * The fan-out is fire-and-forget — delivery failures are logged but do not
+ * affect the caller.
+ */
+export async function fanOutEvent(
+ ctx: PluginContext,
+ eventType: string,
+ payload: unknown,
+): Promise {
+ const { api } = ctx;
+
+ // 1. Store event for polling fallback
+ const eventId = generateEventId();
+ const record: EventRecord = {
+ eventId,
+ type: eventType,
+ payload,
+ timestamp: Date.now(),
+ };
+ await api.db.set(buildEventKey(eventId), record);
+ await purgeOldEvents(api.db.get, api.db.delete, api.db.keys);
+
+ // 2. Collect matching subscribers
+ const hookKeys = await api.db.keys("hook:");
+ if (hookKeys.length === 0) return;
+
+ const hooks = (await Promise.all(hookKeys.map((k) => api.db.get(k)))).filter(
+ (h): h is HookRecord =>
+ h !== null &&
+ (h as HookRecord).targetUrl !== undefined &&
+ ((h as HookRecord).event === eventType ||
+ (h as HookRecord).event === "custom"),
+ );
+
+ // 3. Fan-out: fire-and-forget to each subscriber
+ for (const hook of hooks) {
+ (async () => {
+ try {
+ const response = await api.makeRequest(hook.targetUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: record.eventId,
+ type: eventType,
+ data: payload,
+ timestamp: record.timestamp,
+ }),
+ });
+ if (!response.ok) {
+ api.log(
+ `Zapier fan-out to ${hook.targetUrl} failed: HTTP ${response.status}`,
+ "warn",
+ );
+ }
+ } catch (err) {
+ api.log(
+ `Zapier fan-out delivery error for hook ${hook.hookId}: ${(err as Error).message}`,
+ "error",
+ );
+ }
+ })();
+ }
+}
+
+// ── Plugin Definition ─────────────────────────────────────────────────────────
+
+export default createPlugin({
+ name: "zapier-bridge",
+ version: "1.0.0",
+ description:
+ "Expose Agentbase events as Zapier triggers and accept inbound Zapier actions, connecting your AI app to 7,000+ integrations.",
+ permissions: ["network:external", "db:readwrite"],
+ settings: {
+ enableTriggers: {
+ type: "boolean",
+ label: "Enable Zapier Triggers",
+ default: true,
+ },
+ enableActions: {
+ type: "boolean",
+ label: "Enable Inbound Zapier Actions",
+ default: true,
+ },
+ allowedActions: {
+ type: "string",
+ label:
+ "Allowed Inbound Actions (comma-separated: send_message, emit_event, update_context)",
+ default: "send_message,emit_event",
+ },
+ actionSecret: {
+ type: "string",
+ label: "Action Endpoint Secret (optional — sent as X-Zapier-Secret)",
+ encrypted: true,
+ },
+ },
+
+ hooks: {
+ "app:init": async (context) => {
+ const { api } = context;
+
+ // ── POST /subscribe ─────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "POST",
+ path: "/subscribe",
+ auth: true,
+ description: "Register a Zapier REST Hook subscription",
+ handler: async (req, res) => {
+ const enableTriggers =
+ (api.getConfig("enableTriggers") as boolean) ?? true;
+ if (!enableTriggers) {
+ res.status(403).json({ error: "Triggers are disabled" });
+ return;
+ }
+
+ const { targetUrl, event } = req.body as {
+ targetUrl?: string;
+ event?: string;
+ };
+
+ if (!targetUrl || typeof targetUrl !== "string") {
+ res.status(400).json({ error: "targetUrl is required" });
+ return;
+ }
+ if (!event || typeof event !== "string") {
+ res.status(400).json({ error: "event is required" });
+ return;
+ }
+ if (
+ !(SUPPORTED_EVENTS as readonly string[]).includes(event) &&
+ event !== "custom"
+ ) {
+ res.status(400).json({
+ error: `Unsupported event '${event}'. Supported: ${SUPPORTED_EVENTS.join(", ")}`,
+ });
+ return;
+ }
+
+ const hookId = generateHookId();
+ const hook: HookRecord = {
+ hookId,
+ targetUrl,
+ event,
+ createdAt: Date.now(),
+ };
+ await api.db.set(buildHookKey(hookId), hook);
+
+ res.status(201).json({ id: hookId, event, targetUrl });
+ },
+ });
+
+ // ── DELETE /subscribe/:hookId ───────────────────────────────────────────
+ api.registerEndpoint({
+ method: "DELETE",
+ path: "/subscribe/:hookId",
+ auth: true,
+ description: "Unregister a Zapier REST Hook subscription",
+ handler: async (req, res) => {
+ const hookId = req.params?.["hookId"] as string | undefined;
+ if (!hookId) {
+ res.status(400).json({ error: "hookId is required" });
+ return;
+ }
+
+ const existing = await api.db.get(buildHookKey(hookId));
+ if (!existing) {
+ res.status(404).json({ error: "Hook not found" });
+ return;
+ }
+
+ await api.db.delete(buildHookKey(hookId));
+ res.status(200).json({ deleted: true, hookId });
+ },
+ });
+
+ // ── GET /subscribe ──────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "GET",
+ path: "/subscribe",
+ auth: true,
+ description: "List all active Zapier REST Hook subscriptions",
+ handler: async (_req, res) => {
+ const hookKeys = await api.db.keys("hook:");
+ const hooks = (
+ await Promise.all(hookKeys.map((k) => api.db.get(k)))
+ ).filter((h): h is HookRecord => h !== null);
+
+ res.status(200).json({ hooks });
+ },
+ });
+
+ // ── POST /action ────────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "POST",
+ path: "/action",
+ auth: false,
+ description: "Accept an inbound action from Zapier",
+ handler: async (req, res) => {
+ const enableActions =
+ (api.getConfig("enableActions") as boolean) ?? true;
+ if (!enableActions) {
+ res.status(403).json({ error: "Inbound actions are disabled" });
+ return;
+ }
+
+ // Verify optional action secret
+ const configuredSecret =
+ (api.getConfig("actionSecret") as string) ?? "";
+ if (configuredSecret) {
+ const providedSecret = req.headers["x-zapier-secret"] as
+ | string
+ | undefined;
+ if (!providedSecret || providedSecret !== configuredSecret) {
+ res.status(401).json({ error: "Invalid or missing secret" });
+ return;
+ }
+ }
+
+ const payload = req.body as ZapierActionPayload;
+ if (!payload.action || typeof payload.action !== "string") {
+ res.status(400).json({ error: "action field is required" });
+ return;
+ }
+ if (!payload.data || typeof payload.data !== "object") {
+ res.status(400).json({ error: "data field is required" });
+ return;
+ }
+
+ const allowedActions = (
+ (api.getConfig("allowedActions") as string | undefined) ??
+ "send_message,emit_event"
+ )
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+ if (!allowedActions.includes(payload.action)) {
+ res.status(403).json({
+ error: `Action '${payload.action}' is not in the allowed actions list`,
+ });
+ return;
+ }
+
+ switch (payload.action as SupportedAction) {
+ case "send_message": {
+ const { message, userId } = payload.data as {
+ message?: string;
+ userId?: string;
+ };
+ if (!message || typeof message !== "string") {
+ res
+ .status(400)
+ .json({ error: "data.message is required for send_message" });
+ return;
+ }
+ await api.events.emit("zapier:send_message", {
+ message,
+ userId,
+ zapId: payload.zapId,
+ });
+ res.status(200).json({ ok: true, action: "send_message" });
+ return;
+ }
+
+ case "emit_event": {
+ const { eventName, eventData } = payload.data as {
+ eventName?: string;
+ eventData?: unknown;
+ };
+ if (!eventName || typeof eventName !== "string") {
+ res
+ .status(400)
+ .json({ error: "data.eventName is required for emit_event" });
+ return;
+ }
+ await api.events.emit(`zapier:${eventName}`, {
+ data: eventData,
+ zapId: payload.zapId,
+ });
+ res
+ .status(200)
+ .json({ ok: true, action: "emit_event", eventName });
+ return;
+ }
+
+ case "update_context": {
+ const { key, value } = payload.data as {
+ key?: string;
+ value?: unknown;
+ };
+ if (!key || typeof key !== "string") {
+ res
+ .status(400)
+ .json({ error: "data.key is required for update_context" });
+ return;
+ }
+ await api.events.emit("zapier:update_context", {
+ key,
+ value,
+ zapId: payload.zapId,
+ });
+ res.status(200).json({ ok: true, action: "update_context", key });
+ return;
+ }
+
+ default:
+ res
+ .status(400)
+ .json({ error: `Unknown action '${payload.action}'` });
+ }
+ },
+ });
+
+ // ── GET /events ─────────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "GET",
+ path: "/events",
+ auth: false,
+ description:
+ "Polling fallback — return up to 100 recent events for Zapier sample data",
+ handler: async (req, res) => {
+ const eventType = req.query?.["type"] as string | undefined;
+ const limitParam = req.query?.["limit"] as string | undefined;
+ const limit = Math.min(
+ parseInt(limitParam ?? "25", 10) || 25,
+ MAX_STORED_EVENTS,
+ );
+
+ const eventKeys = await api.db.keys("event:");
+ const events = (
+ await Promise.all(eventKeys.map((k) => api.db.get(k)))
+ ).filter((e): e is EventRecord => e !== null);
+
+ const filtered = eventType
+ ? events.filter((e) => e.type === eventType)
+ : events;
+ filtered.sort((a, b) => b.timestamp - a.timestamp);
+
+ res.status(200).json({ events: filtered.slice(0, limit) });
+ },
+ });
+ },
+
+ // ── conversation:end ─────────────────────────────────────────────────────
+ "conversation:end": async (context) => {
+ const enableTriggers =
+ (context.api.getConfig("enableTriggers") as boolean) ?? true;
+ if (!enableTriggers) return;
+ await fanOutEvent(context, "conversation.end", {
+ appId: context.appId,
+ userId: context.userId,
+ conversationId: (context as unknown as Record)[
+ "conversationId"
+ ],
+ timestamp: Date.now(),
+ });
+ },
+
+ // ── user:register ────────────────────────────────────────────────────────
+ "user:register": async (context) => {
+ const enableTriggers =
+ (context.api.getConfig("enableTriggers") as boolean) ?? true;
+ if (!enableTriggers) return;
+ await fanOutEvent(context, "user.register", {
+ appId: context.appId,
+ userId: context.userId,
+ timestamp: Date.now(),
+ });
+ },
+ },
+});
diff --git a/packages/plugins/official/zapier-bridge/tsconfig.json b/packages/plugins/official/zapier-bridge/tsconfig.json
new file mode 100644
index 0000000..7f8ba5d
--- /dev/null
+++ b/packages/plugins/official/zapier-bridge/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "commonjs",
+ "outDir": "./dist",
+ "rootDir": "../..",
+ "baseUrl": ".",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "strict": true,
+ "noEmit": true,
+ "types": ["node"],
+ "paths": {
+ "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"]
+ }
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist", "__tests__"]
+}
diff --git a/packages/plugins/official/zapier-bridge/tsconfig.test.json b/packages/plugins/official/zapier-bridge/tsconfig.test.json
new file mode 100644
index 0000000..a3367a8
--- /dev/null
+++ b/packages/plugins/official/zapier-bridge/tsconfig.test.json
@@ -0,0 +1,11 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "types": ["jest", "node"],
+ "paths": {
+ "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"]
+ }
+ },
+ "include": ["src/**/*", "__tests__/**/*"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ca1795b..6360fc3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -466,6 +466,28 @@ importers:
specifier: ^5.7.0
version: 5.9.3
+ packages/plugins/official/zapier-bridge:
+ dependencies:
+ '@agentbase/plugin-sdk':
+ specifier: workspace:*
+ version: link:../..
+ devDependencies:
+ '@types/jest':
+ specifier: ^29.5.0
+ version: 29.5.14
+ '@types/node':
+ specifier: ^25.5.2
+ version: 25.5.2
+ jest:
+ specifier: ^29.7.0
+ version: 29.7.0(@types/node@25.5.2)
+ ts-jest:
+ specifier: ^29.2.0
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3)
+ typescript:
+ specifier: ^5.7.0
+ version: 5.9.3
+
packages/plugins/template:
dependencies:
'@agentbase/plugin-sdk':