An integration is nine files across two auth planes and two enum migrations. A plugin is a directory. This merges them: one manifest, one server module that answers both tool calls and webhook deliveries, zero migrations per provider — so writing a new integration becomes writing one folder.
integration_connections — org-scoped, provider is a pgEnumconnect + callback route pair per provideringest.webhook_events, enqueuenormalizeXDelivery.ts fetches what the delivery points at and flattens itMatchableEvent + matchX()INTEGRATIONS with i18n label, category, triggerKindsplugin_connections — user-scoped, per install, manifest-drivenconnect + callback pair reads the manifestserver/index.mjspath + ref + integrityBoth planes cover the same products — Linear, Notion, Slack, GitHub, Google — with separate tokens, separate scopes, separate refresh and separate revoke. Connecting Notion in one place does nothing for the other.
| Surface | Integration | Plugin | Merged |
|---|---|---|---|
| pgEnum migrations | 2 | 0 | 0 |
| API routes | 3–4 | 0 | 0 |
Typed match code in packages/shared | ~3 files | 0 | 0 |
| Catalog entries | 1 + icon | 1 + icon | 1 icon |
| Connection rows per user | 1 | 1 | 1 shared |
| Total places to land a change | 9 | 2 | 1 directory |
plugin.jsonAgent Plugins 1.0 at the top level, everything Superset-specific under a reverse-DNS extension key. Four blocks: what it looks like, where its server is, how it connects, what it emits.
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "acme",
"version": "1.0.0",
"description": "…",
"license": "MIT",
"extensions": {
"sh.superset": {
"interface": { "displayName": "Acme", "category": "Productivity", "icon": "acme" },
// ── where the server is ──────────────────────────────────────
// exactly one of `url` (remote) or `module` (bundled index.mjs).
// Both speak MCP 2026-07-28. Nothing else differs.
"server": {
"url": "https://mcp.acme.com/mcp"
// or: "module": "server/index.mjs" → publish adds integrity + ref
},
// ── how it connects ──────────────────────────────────────────
// a list, not a singleton: a plugin may need more than one
// credential — per audience, or bot + user. See §05.
"connection": {
"scope": "org", // org | user
"identity": { "tool": "get_self", "id": "$.org.id", "label": "$.org.name" }
// no authorization_url / token_url / scopes / requires_env:
// RFC 9728 discovery is the path. `legacyOauth` is the exception.
},
// ── what it emits ────────────────────────────────────────────
"events": {
"ingress": {
"path": "acme", // POST /api/plugins/acme/events
"verify": { "scheme": "hmac-sha256", "header": "X-Acme-Signature",
"prefix": "sha256=", "over": "{body}",
"secret": "connection.config.webhook_secret" },
"eventId": "$.id", // dedup key
"account": "$.org_id" // → connections.external_account_id
// normalization is always the server module's superset_normalize_event
},
"lifecycle": { "register": true, "renewEveryHours": 144 }, // optional; see §05
"triggers": [
{ "event": "issue.created", "title": "Issue created",
"filters": [
{ "name": "team", "label": "Team", "attribute": "teamId",
"options": { "tool": "list_teams", "id": "$.id", "label": "$.name" } }
] }
]
}
}
}
}
authorization_url, token_urlscopes, scope_separatorrequires_env, pkcetoken_request_auth_methodbind — Bearer is a MUST nowclient: "dynamic" — not a mode, the pathconnection.scope — replaces hardcoded index predicatesevents.ingress — verify, dedup, account routingevents.triggers[] — events + filters + option sourcesevents.lifecycle — webhook registration and renewalserver.module — bundled and remote, one field pairThe crux. A plugin's index.mjs stops being "a bundled tool provider" and becomes the plugin's whole server-side implementation — tools for the agent, verification and normalization for the webhook — behind one ABI.
Today dispatch.ts carries two paths. remoteTarget() builds a URL and headers and speaks JSON-RPC; bundledDispatch() downloads a module, checks its digest, imports it, and calls run({ event: "call-tool", eventBody, config }) — a different ABI, with the access token passed inside config instead of a header. Two vocabularies to keep in sync by hand.
MCP 2026-07-28 removes the session handshake, so a call is a pure function from request to response. That makes "server over HTTP" and "function in this process" the same shape:
// plugins/<name>/src/index.ts — the only entrypoint a plugin needs
export async function handle(
request: JsonRpcRequest, // server/discover | tools/list | tools/call
ctx: { headers: Headers }, // Authorization: Bearer … — same as remote
): Promise<JsonRpcResponse>
Dispatch collapses to: build the JSON-RPC request, then either fetch it to server.url or hand it to server.module's handle. The digest pinning, the 8 MB cap, the tmpdir cache and the "manifest ref beats marketplace branch" rule all survive untouched — they are about trusting bytes, not about the protocol.
| Tool | Called by | When | Required |
|---|---|---|---|
list_*, create_*, … | the agent, via the proxy | tool call | — |
superset_normalize_event | ingest | every delivery | if events is declared |
superset_verify_delivery | ingress, before anything else | every delivery | only if verify.scheme = "tool" |
superset_register_delivery | connect flow | on connect | only if lifecycle.register |
superset_renew_delivery | one shared cron | per renewEveryHours | only if lifecycle.renewEveryHours |
These are ordinary MCP tools, called with the same connection and the same credential injection as any other. Three consequences worth stating:
server/discover, not a naming convention — the superset_ prefix is for humans reading the file.Mcp-Name is a header in 2026-07-28, so ingest is allowlisted to exactly these tool names without parsing a body.index.mjs that implements only the superset_* tools and leaves the agent-facing tools to the remote URL. That is the one case where server carries both a url and a module.MRTR — a tool answering resultType: "input_required" to ask the user something mid-call — is right for agent tool calls and wrong here, where no user is present. Ingest must treat input_required from a superset_* tool as a hard failure, not something to satisfy.
plugins/acme/
├── plugin.json // §02 — declaration only
├── src/index.ts // tools + superset_* handlers
├── server/index.mjs // built by `superset plugins build`, committed
└── skills/…/SKILL.md // optional
superset plugins create acme // scaffold
superset plugins publish acme // bump, rebuild, rewrite generated manifests
git tag acme@1.0.0 // the tag is the release
No migration, no API route, no packages/shared edit, no enum. That is the whole point of the merge: the thing an agent has to hold in its head to add Asana is one folder and two commands.
/api/plugins/:name/events. HMAC per the manifest, or superset_verify_delivery.eventId, store the raw body, answer. Nothing is acknowledged before it is durable, and nothing waits on a tool call to be acknowledged.account JSONPath → the connection whose external_account_id matches.superset_normalize_event with that connection's token.attributes[…], then automation_events → QStash → run.Everything typed per-provider today — MatchableEvent, xEventNames(), matchX(), the TriggerConfigInput variant — collapses into what superset_normalize_event returns:
{ "events": [ {
"eventTypes": ["comment.created", "comment.mentioned"], // one delivery, many names
"externalEventId": "e02c…",
"resourceKey": "notion:page_9f2b", // runs debounce per subject
"title": "Re: Q4 planning",
"url": "https://…",
"actor": { "id": "u_44", "label": "Priya", "isExternal": false },
"attributes": { "teamId": "t_1", "labelIds": ["l_3"], "assigneeId": "u_9" },
"payload": { "…" } // delivery + fetched context, for the prompt
} ] }
Matching becomes one function: for each filter the manifest declares, does the trigger's stored TriggerScope allow attributes[filter.attribute]? The existing scopeAllows semantics carry over verbatim. A filter may only name an attribute the plugin declares it emits — enforced by check:plugins at publish time, which is where the compile-time guarantee goes.
automation_triggers
kind -- now only: schedule | webhook | plugin
plugin_name text -- non-null exactly when kind = 'plugin'
event text -- the manifest trigger's `event`
config jsonb -- { filters: { team: TriggerScope, … } }
-- dispatcher indexes untouched: (next_run_at) where kind='schedule',
-- (organization_id, kind). The kind↔config CHECK is replaced.
connections -- integration_connections ∪ plugin_connections
owner_kind text -- 'user' | 'org', from manifest connection.scope
issuer, resource -- the real OAuth key: two plugins on one server share a row
external_account_id, external_account_label, scopes[], config,
access_token, refresh_token, token_expires_at, disconnected_at
unique (owner_kind, coalesce(organization_id, user_id), issuer, resource,
external_account_id) where disconnected_at is null
automation_events provider → plugin_name; integration_connection_id → connection_id;
+ attributes jsonb -- re-dispatch without re-normalizing
ingest.webhook_events provider: integration_provider → text
schedule and webhook stay first-class kinds — they have no plugin behind them. Every provider kind collapses into plugin, and that is the migration that stops recurring.
If Slack's app-home and Linear's mirror are consumers of the plugin's pipe (B5), then something has to gate them and something has to give them a credential. Those are two different things, and the second one has a sharp edge.
plugin_installs is user-scoped — user_id is notNull, organization_id is nullable, and the unique index is on (user_id, marketplace, plugin_name). An install is what materializes skills and MCP config onto your machines. Slack's app-home and Linear's mirroring are org behaviour. Gate them on an install and one person uninstalling takes down the workspace's Slack app.
| Install | Connection | |
|---|---|---|
| Scope | per user | owner_kind: user or org, from the manifest |
| Governs | skills on disk, MCP entries in agent configs, the version pin | whether the product may act as you against that provider |
| Gates a product flow | no | yes |
| Gates agent tools | yes | yes — both |
So: Linear mirroring runs for an org that has an active org-scoped Linear connection. Whether anyone in that org installed the plugin on a laptop is unrelated. The single surface a contributor touches is still one directory — that was always about authoring, not about runtime gating.
This is the part that does not come for free. getLinearClient(organizationId) takes a token out of integration_connections and calls api.linear.app/graphql. A token minted through the plugin's flow is bound by RFC 8707 to resource = https://mcp.linear.app/mcp, and 2026-07-28 is explicit in both directions: servers MUST validate that a token was issued for them, and clients MUST NOT send a token anywhere other than the server its authorization server issued it for. "Take the token from the connected Linear plugin and use it for our own flows" is exactly the thing the spec tightened.
| Option | What it means | Verdict |
|---|---|---|
| Go through the plugin's tools | the mirror calls list_issues instead of GraphQL | Right where the tool surface is good enough. Vendor tools are shaped for agents — prose summaries, agent-sized pages — not for a sync job that wants every issue since a cursor. |
| Credentials per resource | one consent, two audiences: mcp.linear.app/mcp and api.linear.app. The manifest declares both; connections is already keyed by (issuer, resource), so it is a row per resource sharing one grant | The default. Costs one field in the manifest and nothing in the schema. |
| A separate token for the mirror | what we do today | Precisely the thing this proposal exists to delete. |
Whether an authorization server actually rejects a wrong-audience token varies. Linear may well accept an MCP-issued token against GraphQL today; Google's per-API scoping will not be so forgiving, and any vendor can start enforcing without warning because the spec now tells them to. A design that quietly relies on lax audience validation is a design with a deprecation notice on it.
Slack turns out to be the easy case, and it does not need the per-resource machinery above. Its authorize endpoint takes both scope sets in one call — scope for the bot, user_scope for the user — and oauth.v2.access returns both tokens in one response. One consent screen, one callback, two credentials.
Today the plugin asks for user scopes only and posts to oauth.v2.user.access, which by construction returns just the xoxp- token. Point it at oauth.v2.access, add the bot scopes, and the workspace's xoxb- arrives in the same response that the tools token does:
"connection": [
{ "id": "bot", "scope": "org", "authorizeParam": "scope",
"scopes": ["app_mentions:read", "chat:write", "links:read", "links:write", "…"],
"token": "$.access_token",
"identity": { "id": "$.team.id", "label": "$.team.name" } },
{ "id": "user", "scope": "user", "authorizeParam": "user_scope",
"scopes": ["channels:history", "search:read", "chat:write", "…"],
"token": "$.authed_user.access_token",
"identity": { "tool": "auth_test", "id": "$.user_id" } }
]
// one authorize: ?scope=…&user_scope=… → one POST oauth.v2.access → two rows
Two rows, not one blob. The bot credential belongs to the workspace and the user credential to the person, and their lifecycles differ: someone leaving should invalidate their xoxp- and leave the workspace's xoxb- alone; one bot row per team, N user rows. Both carry the same external_account_id (the team id) so they are obviously one grant, and the second person to connect reuses the bot row and adds only their own. If Slack token rotation is enabled both get refresh tokens and 12-hour expiries, which the per-row columns already handle.
The Slack plugin has no mcp block — it is a bundled module calling slack.com/api directly. So there is no vendor-hosted MCP server and therefore no second audience: the same xoxb- that process-app-home-opened uses is the one the plugin's tools use. The audience problem in B2 only bites where we use a vendor's hosted MCP server and call that vendor's own API — Linear and Notion, not Slack or Gmail. Which is a decent tiebreaker when choosing between a hosted server and a bundled module for a provider we also drive from product code.
If product behaviour depends on a plugin, then publishing a plugin changes product behaviour. A first-party plugin that backs a product flow should be pinned by the API to a known tag, not by whatever version a given account's install happens to point at. Otherwise superset plugins publish linear --bump patch is a production deploy wearing a different hat.
Ranked by how likely they are to stop the change, not by how hard they are to type.
Linear uses one app-level webhook and LINEAR_WEBHOOK_SECRET; Notion and Google register per workspace or per mailbox, and Google's watches expire — hence jobs/renew-watches. A manifest cannot express that, so it needs events.lifecycle plus superset_register_delivery / superset_renew_delivery on the module, driven by one shared cron. This is the single largest piece of new machinery in the proposal and the part most likely to be got wrong quietly — a watch that silently stops renewing looks exactly like a quiet integration.
The premise of reusing one connection for product flows — Linear mirroring, Slack unfurls — is that the token is usable for both. It is audience-bound: minted for resource = https://mcp.linear.app/mcp, while getLinearClient() calls api.linear.app/graphql. 2026-07-28 says servers MUST check they are the audience and clients MUST NOT send a token elsewhere. Fix is credentials-per-resource on one grant (§05), which is cheap — but it has to be designed in, not discovered when a vendor starts enforcing.
It runs in-process in the API, with a credential, returning data that decides which automations fire. First-party only until it is sandboxed. trustedManifest(marketplace) already exists and already gates identity probes — extend it so an untrusted marketplace may ship server and skills but not events. Consequence worth accepting out loud: the contribution-surface win is immediate for us and only eventual for outside contributors.
_EveryKindHasEnumValue currently makes enum drift a type error, and each provider's trigger config is a typed zod variant. Under attributes the guarantee moves to check:plugins: every filter must name a declared attribute, every declared attribute must be emitted. Weaker, and the main thing to argue about. Mitigation: the plugin declares its attribute set in the manifest and the CI check diffs it against a recorded fixture per event type.
issuer and resource that were never recorded
Existing integration_connections rows predate both fields. Inferring them from the provider is correct for the seven current providers and is exactly the kind of shortcut that outlives its excuse. Also: ingest.webhook_events.provider is a pgEnum on a large table with a (provider, event_id) unique index — the type change is routine but not free.
Slack's app-home, mentions, interactions and link-unfurling are a second thing entirely: Superset as a Slack app, answering back into the product rather than starting an automation. Linear's task mirroring, initial sync and sweeps are a third: keeping our own tasks table in step with theirs. Neither belongs in a plugin.
But "so handle Slack separately" is the wrong cut, because it leaves the duplication where it actually is. slack/events, slack/interactions, slack/link and the trigger route each verify a signature, resolve a connection and dedup a delivery — four times, per provider. The fix is that events.ingress does not belong to triggers. One pipe verifies, resolves the connection and stores once, then fans out to consumers; the trigger matcher is one subscriber, Slack's app-home handler is another, Linear's mirror is a third. The handlers stay exactly where they are in apps/api and stop owning routes.
Two constraints that fall out of it. Slack wants an ack in about three seconds, so the acknowledgement happens after verify-and-store and before any MCP call — which is why normalization sits behind the fan-out rather than in front of it. And normalization is per-consumer, not global: the interactive path wants the raw payload, only the trigger consumer wants attributes. Keep the routes separate instead and the merge half-lands — two verifiers, two connection lookups, two dedup stores, and a Slack signing secret that has to be understood in two places. What gates each consumer, and where it gets a credential, is §05.
verify.secret has to be able to name an env var (Linear, Slack) as well as a connection field (Notion, Acme). Env vars in this repo mean the five-places problem in docs/environment-variables.md, and a plugin naming an env var the API has never heard of must fail at publish, not at first delivery.
Normalization becomes an MCP call. Store-then-process ordering already holds, so failures are retryable rather than lost — but delivery-to-run latency now includes a tool call, and a slow provider becomes a slow trigger. Needs a timeout, a retry budget, and a dead-letter that is visible.
Between them they exercise everything: remote MCP and bundled module, spec-compliant OAuth and legacy OAuth, HMAC ingress and tool-verified ingress, filters with remote option sources.
{
"name": "linear", "version": "2.0.0", "license": "MIT",
"description": "Plan and build products: create, search and update Linear issues.",
"extensions": { "sh.superset": {
"interface": { "displayName": "Linear", "category": "Productivity", "icon": "linear" },
"server": {
"url": "https://mcp.linear.app/mcp", // agent-facing tools: theirs
"module": "server/index.mjs" // superset_* tools: ours
},
"connection": {
"scope": "org",
"identity": { "tool": "get_viewer",
"id": "$.organization.id", "label": "$.organization.name" }
// mcp.linear.app advertises PRM + CIMD, so nothing else is needed.
// PLUGIN_LINEAR_CLIENT_ID / _SECRET stay deleted.
},
"events": {
"ingress": {
"path": "linear",
"verify": { "scheme": "hmac-sha256", "header": "linear-signature",
"over": "{body}", "secret": "env.LINEAR_WEBHOOK_SECRET",
"replayWindowSeconds": 60 },
"eventId": "$.webhookId",
"account": "$.organizationId"
},
// app-level webhook configured once in Linear's dashboard — no lifecycle block
"triggers": [
{ "event": "issue.status_changed", "title": "Issue moved to a status",
"filters": [
{ "name": "teams", "attribute": "teamId",
"options": { "tool": "list_teams" } },
{ "name": "projects", "attribute": "projectId",
"options": { "tool": "list_projects" } },
{ "name": "labels", "attribute": "labelIds",
"options": { "tool": "list_labels" } },
{ "name": "toStatus", "attribute": "toStateId",
"options": { "tool": "list_workflow_states" } },
{ "name": "assignee", "attribute": "assigneeId",
"options": { "tool": "list_users" } }
] },
{ "event": "comment.created", "title": "Comment on an issue",
"filters": [ { "name": "teams", "attribute": "teamId",
"options": { "tool": "list_teams" } } ] }
]
}
} }
}
That block replaces: the linear pgEnum values, linearTriggerConfigSchema, LinearMatchableEvent + matchLinear(), the connect/callback pair, and the INTEGRATIONS entry. It does not replace jobs/process-delivery, jobs/initial-sync or jobs/sweep-abandoned — that is task mirroring, which is not a trigger (B5). The Hookdeck path in front of the route stays; it is ingress plumbing, not per-provider logic.
{
"name": "gmail", "version": "2.0.0", "license": "MIT",
"extensions": { "sh.superset": {
"interface": { "displayName": "Gmail", "category": "Communication", "icon": "gmail" },
"server": { "module": "server/index.mjs" }, // integrity + ref added by publish
"connection": {
"scope": "user", // a mailbox is one person's
"identity": { "tool": "get_profile", "id": "$.emailAddress" },
// accounts.google.com implements neither PRM nor CIMD — the
// documented exception, and check:plugins warns if a server that
// DOES advertise PRM still ships one of these.
"legacyOauth": {
"issuer": "https://accounts.google.com",
"requiresEnv": ["PLUGIN_GOOGLE_CLIENT_ID", "PLUGIN_GOOGLE_CLIENT_SECRET"],
"scopes": ["https://www.googleapis.com/auth/gmail.modify", "…"],
"authorizationParams": { "access_type": "offline", "prompt": "consent" }
}
},
"events": {
"ingress": {
"path": "gmail",
// Pub/Sub push: a Google-signed JWT against a rotating JWKS, not an
// HMAC over the body. Declarative verification cannot express it.
"verify": { "scheme": "tool" },
"eventId": "$.message.messageId",
"account": "$.message.attributes.emailAddress"
},
// watches expire after 7 days; this is what replaces jobs/renew-watches
"lifecycle": { "register": true, "renewEveryHours": 144 },
"triggers": [
{ "event": "message.received", "title": "New mail arrives",
"filters": [
{ "name": "labels", "attribute": "labelIds",
"options": { "tool": "list_labels" } },
{ "name": "from", "attribute": "fromAddress", "free": true }
] }
]
}
} }
}
// plugins/gmail/src/index.ts — one module, both callers
export async function handle(request, ctx) {
const token = bearer(ctx.headers); // same injection as remote
switch (request.method) {
case "server/discover":
return ok({ capabilities: { tools: {},
extensions: { "sh.superset/events": {} } } }); // how ingest knows
case "tools/list":
// superset_* tools are filtered out unless the caller is ingest
return ok({ tools: agentTools(ctx), ttlMs: 3_600_000 });
case "tools/call": {
const { name, arguments: args } = request.params;
switch (name) {
case "superset_verify_delivery":
return ok(await verifyPubSubJwt(args.headers, args.body));
case "superset_normalize_event": {
// the historyId means nothing without a history walk — the same
// walk apps/api/.../google/lib/syncMailbox.ts does today
const msgs = await historySince(token, args.body.historyId);
return ok({ events: msgs.map(toNormalizedEvent) });
}
case "superset_register_delivery":
return ok(await users.watch(token, { topicName: args.topic }));
case "superset_renew_delivery":
return ok(await users.watch(token, { topicName: args.topic }));
default:
return ok(await callTool(name, args, token)); // today's handlers, unchanged
}
}
}
}
Two things fall out of this that are worth more than the tidiness. apps/api/.../google/lib/syncMailbox.ts and the plugin's own history handler stop being two implementations of one walk, versioned separately, with the webhook copy invisible to superset plugins publish. And jobs/renew-watches stops being Google-specific: one cron walks every connection whose plugin declares renewEveryHours and calls its renew tool.
Create connections, backfill both tables into it, leave integration_connections as a view. Fixes the double-connect and gives one revoke. Nothing else changes.
Stateless dispatch with the initialize fallback retained; bundled modules move to handle(). Pure refactor of the tools plane — no schema, no manifest change.
PRM becomes the default, the static block becomes legacyOauth. Fold in the open OAuth review findings, and add the RFC 9207 iss check the callback is missing today.
Generic ingress, generic matcher, attributes, lifecycle cron. Port one provider behind a flag with the old path still live, and compare matches on real deliveries before porting the rest.
Phases 1 and 2 are worth doing whichever way 3 and 4 go. Phase 4 is the one that can silently stop firing someone's automation, which is why it ports one provider first and compares rather than cutting over.