From d4a1bd0af26db3cefc50353fc26d8941244246e3 Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:15:23 -0500 Subject: [PATCH 1/5] feat(mcp): say sapiom.* platform events are subscribable in the schedule tool [SAP-3864] Co-Authored-By: Claude Opus 5.5 (1M context) --- .../mcp-platform-events-subscribable.md | 5 ++ packages/mcp/src/instructions.generated.ts | 24 +++++- packages/mcp/src/instructions.test.ts | 5 +- .../mcp/src/tools/agents.schedules.test.ts | 85 +++++++++++++++++++ packages/mcp/src/tools/agents.ts | 11 ++- 5 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 .changeset/mcp-platform-events-subscribable.md diff --git a/.changeset/mcp-platform-events-subscribable.md b/.changeset/mcp-platform-events-subscribable.md new file mode 100644 index 000000000..364e9a674 --- /dev/null +++ b/.changeset/mcp-platform-events-subscribable.md @@ -0,0 +1,5 @@ +--- +"@sapiom/mcp": patch +--- + +The schedule tool now says `sapiom.*` platform events can be subscribed to with an `event` trigger (never emitted) and points to the server instructions for the list; the bundled primer now documents `sapiom.run.failed`. diff --git a/packages/mcp/src/instructions.generated.ts b/packages/mcp/src/instructions.generated.ts index a55ce7d6d..521f8d1a9 100644 --- a/packages/mcp/src/instructions.generated.ts +++ b/packages/mcp/src/instructions.generated.ts @@ -1,6 +1,6 @@ // GENERATED by scripts/mcp-instructions-snapshot.mjs — do not edit. // Source: GET https://api.sapiom.ai/v1/mcp/instructions -// Content release 2.14, sha-256 055076ab6773f92133a88da8aae71e2130f4c693a79779176c3e0707cb4ffeac. +// Content release 2026-10-03, sha-256 00bebe154f800dac263cd875b151ae647b8eb66bd914ae996c82ecc8f9f8be6e. // // This is the bundled snapshot of the authoring primer: the last source // instructions-fetch.ts falls back to, after the live copy and the last-known-good @@ -69,6 +69,15 @@ and self-contained; republishing the same slug replaces the app in place at the Org-scoped by default; \`public\` needs an explicit confirmation and a daily spend cap because your org pays for every wake. See https://docs.sapiom.ai/capabilities/app-links. +Never put a Sapiom API key in an App Link's env: publish rejects \`SAPIOM_API_KEY\` there. Each +publish mints the link its own key in the publishing org, injects it at wake as +\`SAPIOM_API_KEY\` (with \`SAPIOM_API_URL\`, plus \`SAPIOM_DEFINITION_ID\` when an agent's run +published it), and revokes the previous one; deleting the link revokes it too. The key is +least-privilege: it reads only the publishing agent's own secrets, so +\`vault.get("workflow:" + process.env.SAPIOM_DEFINITION_ID, key)\` from \`@sapiom/tools\` works +with no setup, and no other Vault ref does; it holds \`org.read\` only if the publisher did, and +it cannot write or spend. + An App Link is a wake-on-visit **redirector, not a reverse proxy**: no sub-path is forwarded, so the app's own API lives at whichever preview URL is serving the app, never under the link. To get that address, request the root WITHOUT following redirects — a WARM app answers 302 and @@ -117,6 +126,11 @@ into a webhook trigger. rotates or revokes a webhook secret. The \`event\` and \`webhook\` kinds and the secret tool need \`@sapiom/mcp\` >= 0.15 — on an older client, use the REST trigger routes in the guide: https://docs.sapiom.ai/guides/triggers. +\`sapiom.*\` event types are reserved, except \`sapiom.run.failed\`: the platform emits it when a run +of another agent in this tenant ends \`failed\` (never to the failing agent, and never again from a +run it started), with \`definitionId\`, \`slug\`, \`definitionName\`, \`executionId\`, \`failedStep\`, +\`attempt\` (0-based), \`faultClass\`, \`startedAt\` and \`finishedAt\` (no error text: read the run). +Listen with an \`event\` trigger; the events API refuses it. ## Canonical rules (types are the source of truth — run \`npm run typecheck\`) - Import \`defineAgent\`, \`defineStep\`, and the directives @@ -158,6 +172,10 @@ https://docs.sapiom.ai/guides/triggers. may be present). For structured/JSON output, use the \`output\` param — it forces a tool call under the hood, so the payload is always the \`tool_use\` block's \`input\` — never "reply with only JSON" + string parsing. +- **Fixed answer set and you need a probability → \`ctx.sapiom.decisions.evaluate\`** — yes/no, pick-one, or + rubric score with a System One decision model. Ask all independent questions over the same state + in one call. Use separate calls when a question needs an earlier answer. It is not a security + boundary and is weak at arithmetic/date math — keep those in code. - **Platform-driven agent loop → \`ctx.sapiom.models.run\`** — a multi-turn reasoning + tool-calling task (minutes, not seconds). \`models.coding.run\` for sandboxed coding tasks. Never use this for a one-shot completion — it will loop and overthink. @@ -181,8 +199,8 @@ https://docs.sapiom.ai/guides/connect-claude-code-with-mcp, plus the \`AGENTS.md your scaffolded project.`; /** `X-Sapiom-Content-Release` of the response this snapshot was taken from. */ -export const AUTHORING_INSTRUCTIONS_RELEASE = "2.14"; +export const AUTHORING_INSTRUCTIONS_RELEASE = "2026-10-03"; /** Full sha-256 hex of {@link AUTHORING_INSTRUCTIONS}; the served header carries its first 12 characters. */ export const AUTHORING_INSTRUCTIONS_DIGEST = - "055076ab6773f92133a88da8aae71e2130f4c693a79779176c3e0707cb4ffeac"; + "00bebe154f800dac263cd875b151ae647b8eb66bd914ae996c82ecc8f9f8be6e"; diff --git a/packages/mcp/src/instructions.test.ts b/packages/mcp/src/instructions.test.ts index b6bc642b1..894979341 100644 --- a/packages/mcp/src/instructions.test.ts +++ b/packages/mcp/src/instructions.test.ts @@ -33,7 +33,10 @@ describe("server instructions", () => { // from the served endpoint, body and digest together. Editing the body by hand // fails this; the fix is to re-run the script, never to re-point the digest. expect(AUTHORING_INSTRUCTIONS_DIGEST).toMatch(/^[0-9a-f]{64}$/); - expect(AUTHORING_INSTRUCTIONS_RELEASE).toMatch(/^\d+\.\d+$/); + // A release id is either a version (2.14) or a date (YYYY-MM-DD). + expect(AUTHORING_INSTRUCTIONS_RELEASE).toMatch( + /^(\d+\.\d+|\d{4}-\d{2}-\d{2})$/, + ); const sha256 = createHash("sha256") .update(AUTHORING_INSTRUCTIONS, "utf8") .digest("hex"); diff --git a/packages/mcp/src/tools/agents.schedules.test.ts b/packages/mcp/src/tools/agents.schedules.test.ts index 030ed75f4..b1b83956c 100644 --- a/packages/mcp/src/tools/agents.schedules.test.ts +++ b/packages/mcp/src/tools/agents.schedules.test.ts @@ -199,6 +199,91 @@ describe("agent schedule MCP tools", () => { expect(out.hint).toContain("/v1/workflows/events"); }); + // SAP-3864: platform events are subscribable but never emitted, so the hint must not send + // the agent to the emit route. The rule is the 'sapiom.' prefix, not a list of types. + function platformEventTrigger(eventType: string) { + return { + id: "trig-4", + kind: "event", + status: "active", + definitionSlug: "watchdog", + cron: null, + timezone: null, + eventType, + publicId: null, + secretVersion: null, + graceUntil: null, + revokedAt: null, + nextFireAt: null, + createdAt: "x", + input: {}, + startAt: null, + endAt: null, + policy: null, + recentFires: [], + } as never; + } + + function expectPlatformEventHint(hint: string) { + expect(hint).toContain("platform"); + expect(hint).toContain("Triggers"); + expect(hint).not.toContain("sapiom_dev_agents_emit_event"); + expect(hint).not.toContain("/v1/workflows/events"); + } + + it("event trigger on sapiom.run.failed hints that the platform emits it", async () => { + vi.mocked(createSchedule).mockResolvedValue( + platformEventTrigger("sapiom.run.failed"), + ); + const { server, handlers } = createMockServer(); + register(server, env); + + const res = await handlers.get("sapiom_dev_agents_schedule")!({ + definition: "watchdog", + kind: "event", + eventType: "sapiom.run.failed", + }); + + const out = parse(res); + expect(out.hint).toContain("sapiom.run.failed"); + expectPlatformEventHint(out.hint); + }); + + it("any sapiom.* event trigger gets the platform hint (prefix rule)", async () => { + vi.mocked(getSchedule).mockResolvedValue( + platformEventTrigger("sapiom.run.succeeded"), + ); + const { server, handlers } = createMockServer(); + register(server, env); + + const res = await handlers.get("sapiom_dev_agents_schedule_inspect")!({ + scheduleId: "trig-4", + }); + + expectPlatformEventHint(parse(res).hint); + }); + + it("schedule tool text says sapiom.* is a subscribable platform namespace", () => { + const { server } = createMockServer(); + register(server, env); + const call = vi + .mocked(server.tool) + .mock.calls.find((c) => c[0] === "sapiom_dev_agents_schedule")!; + const schema = call[2] as unknown as { + kind: { description: string }; + eventType: { description: string }; + }; + const eventTypeDoc = schema.eventType.description; + expect(eventTypeDoc).toContain("'sapiom.*' types are platform events"); + expect(eventTypeDoc).toContain("never emit"); + expect(eventTypeDoc).toContain("'Triggers'"); + // The list lives in the served primer, so the tool names no platform type. + expect(eventTypeDoc).not.toContain("sapiom.run.failed"); + expect(call[1]).toContain("platform emits a 'sapiom.*' event"); + expect(call[1]).toContain("'Triggers'"); + expect(schema.kind.description).toContain("platform 'sapiom.*' event"); + }); + it("webhook trigger create returns the hook URL, the shown-once secret, and the signing scheme", async () => { vi.mocked(createSchedule).mockResolvedValue({ id: "trig-3", diff --git a/packages/mcp/src/tools/agents.ts b/packages/mcp/src/tools/agents.ts index 733f0e4db..2bddc33d5 100644 --- a/packages/mcp/src/tools/agents.ts +++ b/packages/mcp/src/tools/agents.ts @@ -114,8 +114,11 @@ function scheduleHint(schedule: ScheduleDetail): string | undefined { return `Active — next fire at ${schedule.nextFireAt}.`; if (schedule.status === "active" && schedule.kind === "webhook") return `Armed — fires on every signed POST to the hook URL (publicId ${schedule.publicId}, secret v${schedule.secretVersion}). The secret was shown once at create/rotate time; rotate with sapiom_dev_agents_schedule_secret if it is lost.`; - if (schedule.status === "active" && schedule.kind === "event") + if (schedule.status === "active" && schedule.kind === "event") { + if (schedule.eventType?.startsWith("sapiom.")) + return `Armed: fires on every '${schedule.eventType}' platform event. The platform emits it; it cannot be emitted by hand (see the server instructions, section 'Triggers').`; return `Armed — fires on every '${schedule.eventType}' event this tenant emits (sapiom_dev_agents_emit_event, or POST /v1/workflows/events with { type, payload }).`; + } if (schedule.status === "completed") return "Completed — no further fires."; if (schedule.status === "disabled") return schedule.revokedAt @@ -714,7 +717,7 @@ export function register(server: McpServer, env: ResolvedEnvironment): void { registerTool( server, "sapiom_dev_agents_schedule", - "Create a trigger for a deployed agent — one of four kinds. 'schedule_cron' (+ cron, optional timezone) runs it on a recurring schedule; 'schedule_once' (+ at) runs it once at a future time; 'event' (+ eventType) runs it every time this tenant emits that event type via POST /v1/workflows/events; 'webhook' runs it every time an external system POSTs to a public hook URL — the result returns that URL plus a shown-once signing secret and the HMAC-SHA256 scheme the sender must use. Use 'webhook' when an external system should start the agent (\"run when X POSTs to us\") instead of hand-building an HTTP server; third-party senders with their own signature scheme (Slack, Meta, Stripe, GitHub) cannot produce our HMAC, so route those through an App Link /hook/* receiver or a translator. Returns the trigger with its next fire time where it has one. Tip: validate a cron with sapiom_dev_agents_cron_preview first.", + "Create a trigger for a deployed agent — one of four kinds. 'schedule_cron' (+ cron, optional timezone) runs it on a recurring schedule; 'schedule_once' (+ at) runs it once at a future time; 'event' (+ eventType) runs it every time this tenant emits that event type via POST /v1/workflows/events, or the platform emits a 'sapiom.*' event (list in the server instructions, section 'Triggers'); 'webhook' runs it every time an external system POSTs to a public hook URL — the result returns that URL plus a shown-once signing secret and the HMAC-SHA256 scheme the sender must use. Use 'webhook' when an external system should start the agent (\"run when X POSTs to us\") instead of hand-building an HTTP server; third-party senders with their own signature scheme (Slack, Meta, Stripe, GitHub) cannot produce our HMAC, so route those through an App Link /hook/* receiver or a translator. Returns the trigger with its next fire time where it has one. Tip: validate a cron with sapiom_dev_agents_cron_preview first.", { definition: z .string() @@ -724,7 +727,7 @@ export function register(server: McpServer, env: ResolvedEnvironment): void { kind: z .enum(["schedule_cron", "schedule_once", "event", "webhook"]) .describe( - "'schedule_cron' = recurring; 'schedule_once' = a single delayed run; 'event' = fires on a tenant-emitted event type; 'webhook' = fires on a signed POST to a public URL minted for this trigger.", + "'schedule_cron' = recurring; 'schedule_once' = a single delayed run; 'event' = fires on an event type the tenant emits, or on a platform 'sapiom.*' event; 'webhook' = fires on a signed POST to a public URL minted for this trigger.", ), cron: z .string() @@ -748,7 +751,7 @@ export function register(server: McpServer, env: ResolvedEnvironment): void { .string() .optional() .describe( - "Event type to match — required for 'event'. Lowercase dot-separated segments, e.g. 'lead.created'; the 'sapiom.*' namespace is reserved. Emit it with sapiom_dev_agents_emit_event, or POST /v1/workflows/events { type, payload }.", + "Event type to match — required for 'event'. Lowercase dot-separated segments, e.g. 'lead.created'. Emit your own types with sapiom_dev_agents_emit_event, or POST /v1/workflows/events { type, payload }. 'sapiom.*' types are platform events: subscribe to one, never emit it; which ones exist and what they carry is in the server instructions, section 'Triggers'.", ), input: z .unknown() From 1b30f8ade486ea3ad2130d1e6e144c7f3574d14c Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:18:59 -0500 Subject: [PATCH 2/5] chore(mcp): drop the release stamp comment from the generated primer header [SAP-3864] The release and digest are already exported constants right below it. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/mcp/src/instructions.generated.ts | 1 - scripts/mcp-instructions-snapshot.mjs | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/mcp/src/instructions.generated.ts b/packages/mcp/src/instructions.generated.ts index 521f8d1a9..5f18d3666 100644 --- a/packages/mcp/src/instructions.generated.ts +++ b/packages/mcp/src/instructions.generated.ts @@ -1,6 +1,5 @@ // GENERATED by scripts/mcp-instructions-snapshot.mjs — do not edit. // Source: GET https://api.sapiom.ai/v1/mcp/instructions -// Content release 2026-10-03, sha-256 00bebe154f800dac263cd875b151ae647b8eb66bd914ae996c82ecc8f9f8be6e. // // This is the bundled snapshot of the authoring primer: the last source // instructions-fetch.ts falls back to, after the live copy and the last-known-good diff --git a/scripts/mcp-instructions-snapshot.mjs b/scripts/mcp-instructions-snapshot.mjs index d5ffef6bc..72cae75d5 100644 --- a/scripts/mcp-instructions-snapshot.mjs +++ b/scripts/mcp-instructions-snapshot.mjs @@ -78,7 +78,6 @@ function toTemplateLiteral(text) { export function renderModule({ body, release, digest, apiURL }) { return `// GENERATED by scripts/mcp-instructions-snapshot.mjs — do not edit. // Source: GET ${apiURL}${INSTRUCTIONS_PATH} -// Content release ${release}, sha-256 ${digest}. // // This is the bundled snapshot of the authoring primer: the last source // instructions-fetch.ts falls back to, after the live copy and the last-known-good From 3eca928f846f73c60488dbb9817e91afc566b4a7 Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:21:23 -0500 Subject: [PATCH 3/5] test(mcp): check the bundled primer is exactly the generator's output [SAP-3864] Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/mcp-instructions-snapshot.test.mjs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scripts/mcp-instructions-snapshot.test.mjs b/scripts/mcp-instructions-snapshot.test.mjs index b03044855..eb5984589 100644 --- a/scripts/mcp-instructions-snapshot.test.mjs +++ b/scripts/mcp-instructions-snapshot.test.mjs @@ -8,6 +8,10 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; +import prettier from "prettier"; + +import { OUTPUT_PATH, renderModule } from "./mcp-instructions-snapshot.mjs"; + const SCRIPT = fileURLToPath( new URL("./mcp-instructions-snapshot.mjs", import.meta.url), ); @@ -164,3 +168,19 @@ test("writes a stamped body without its footer, then is a no-op on the second ru assert.match(second.stdout, /already matches/); assert.equal(await readFile(out, "utf8"), generated); }); + +// A hand edit anywhere in the committed snapshot, header included, fails this offline: +// the file must be exactly what the generator renders from its own constants. +test("the committed snapshot is the generator's output for its own body", async () => { + const snapshot = await import(OUTPUT_PATH); + const rendered = await prettier.format( + renderModule({ + body: snapshot.AUTHORING_INSTRUCTIONS, + release: snapshot.AUTHORING_INSTRUCTIONS_RELEASE, + digest: snapshot.AUTHORING_INSTRUCTIONS_DIGEST, + apiURL: "https://api.sapiom.ai", + }), + { filepath: OUTPUT_PATH }, + ); + assert.equal(await readFile(OUTPUT_PATH, "utf8"), rendered); +}); From 2987068235ae7d4aa825aaa5500927ed79c23b6e Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:22:37 -0500 Subject: [PATCH 4/5] test(mcp): load the snapshot as a data URL so the check runs on Node 20 [SAP-3864] Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/mcp-instructions-snapshot.test.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/mcp-instructions-snapshot.test.mjs b/scripts/mcp-instructions-snapshot.test.mjs index eb5984589..a3f72ced6 100644 --- a/scripts/mcp-instructions-snapshot.test.mjs +++ b/scripts/mcp-instructions-snapshot.test.mjs @@ -172,7 +172,11 @@ test("writes a stamped body without its footer, then is a no-op on the second ru // A hand edit anywhere in the committed snapshot, header included, fails this offline: // the file must be exactly what the generator renders from its own constants. test("the committed snapshot is the generator's output for its own body", async () => { - const snapshot = await import(OUTPUT_PATH); + // The generated file holds no types, so it loads as JavaScript on every supported Node. + const committed = await readFile(OUTPUT_PATH, "utf8"); + const snapshot = await import( + `data:text/javascript,${encodeURIComponent(committed)}` + ); const rendered = await prettier.format( renderModule({ body: snapshot.AUTHORING_INSTRUCTIONS, @@ -182,5 +186,5 @@ test("the committed snapshot is the generator's output for its own body", async }), { filepath: OUTPUT_PATH }, ); - assert.equal(await readFile(OUTPUT_PATH, "utf8"), rendered); + assert.equal(committed, rendered); }); From a56d37addb95c6f092a995a641d8830639f2df72 Mon Sep 17 00:00:00 2001 From: antoine-berger <64917674+antoine-berger@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:33:39 -0500 Subject: [PATCH 5/5] test(browser-pay): wait for the refused events, not only the failRequest commands [SAP-3864] The interceptor emits 'refused' after the Fetch.failRequest reply comes back, while the stub records the command on receipt, so a loaded runner checked the events too early. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/browser-pay/src/interceptor.protocol.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/browser-pay/src/interceptor.protocol.test.ts b/packages/browser-pay/src/interceptor.protocol.test.ts index 22884240b..1bbe10b5e 100644 --- a/packages/browser-pay/src/interceptor.protocol.test.ts +++ b/packages/browser-pay/src/interceptor.protocol.test.ts @@ -1113,11 +1113,13 @@ describe("interceptor CDP decisions", () => { await new Promise((resolve) => setTimeout(resolve, 40)); expect(calls).toBe(1); release(); + // `refused` is emitted once the failRequest reply comes back, after the stub records the command. await waitFor( () => stub.commands.filter( (command) => command.method === "Fetch.failRequest", - ).length >= 2, + ).length >= 2 && + events.filter((event) => event.type === "refused").length >= 2, "both failed", ); expect(calls).toBe(1);