Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-platform-events-subscribable.md
Original file line number Diff line number Diff line change
@@ -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`.
4 changes: 3 additions & 1 deletion packages/browser-pay/src/interceptor.protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 20 additions & 3 deletions packages/mcp/src/instructions.generated.ts
Original file line number Diff line number Diff line change
@@ -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 2.14, sha-256 055076ab6773f92133a88da8aae71e2130f4c693a79779176c3e0707cb4ffeac.
//
// 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
Expand Down Expand Up @@ -69,6 +68,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
Expand Down Expand Up @@ -117,6 +125,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
Expand Down Expand Up @@ -158,6 +171,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.
Expand All @@ -181,8 +198,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";
5 changes: 4 additions & 1 deletion packages/mcp/src/instructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
85 changes: 85 additions & 0 deletions packages/mcp/src/tools/agents.schedules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 7 additions & 4 deletions packages/mcp/src/tools/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand Down
1 change: 0 additions & 1 deletion scripts/mcp-instructions-snapshot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions scripts/mcp-instructions-snapshot.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down Expand Up @@ -164,3 +168,23 @@ 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 () => {
// 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,
release: snapshot.AUTHORING_INSTRUCTIONS_RELEASE,
digest: snapshot.AUTHORING_INSTRUCTIONS_DIGEST,
apiURL: "https://api.sapiom.ai",
}),
{ filepath: OUTPUT_PATH },
);
assert.equal(committed, rendered);
});
Loading