Build or refactor a Tool-channel provider (PagerDuty, Opsgenie, future incident/alerting tools) to be endpoint-routed: per-subscriber secrets stored encrypted on a ChannelConnection behind a typed channel endpoint, a stateless provider that resolves routing from channelData at send time, SKIPPED steps when no endpoint exists, and the full API/worker/dashboard/docs/playground surface. Starts with a mandatory provider-docs discovery gate. Use when refactoring Opsgenie to the PagerDuty model, adding a new tool provider, or touching pagerduty_service channel endpoints.
76
96%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
PagerDuty is the reference implementation. "Endpoint-routed" means the provider
has no environment-level credentials: every subscriber registers their own
secret (e.g. a PagerDuty Events API v2 routing key) as a channel endpoint, and
one trigger fans out to a different external service per subscriber. Grep for
pagerduty_service / ENDPOINT_ROUTED_TOOL_PROVIDERS to find every touch
point of the reference implementation.
Every invariant and checklist item below encodes a fact from PagerDuty's
Events API v2 docs (32-char key regex, us/eu endpoints, dedup_key, 1024-char
summary limit). Do not transplant those facts onto another provider. First,
read the target provider's official API docs and pin down, with citations:
@Matches), and scope. It must be issuable per recipient/service.region equivalent, or none.dedup_key equivalent and its retry semantics.RESERVED_OVERRIDE_KEYS, and truncation).Then apply the gate:
{ routingKey, region }) is accepted and returned by the API, but persisted
encrypted on a dedicated ChannelConnection.auth (1:1 owned by the
endpoint). The stored ChannelEndpoint.endpoint document is {}. Read
paths re-hydrate the wire shape by decrypting the linked connection.type. Duplicate
POST → 409 Conflict; rotation is a PATCH on the existing endpoint.sendMessage from options.channelData.endpoint, guarded
by isChannelDataOfType(channelData, ENDPOINT_TYPES.X). Missing/wrong
channelData throws.ENDPOINT_ROUTED_TOOL_PROVIDERS (a Set in
send-message-tool.usecase.ts); credential-routed tool providers
(tool-webhook, Opsgenie today) still fall back to integration.credentials.dedup_key from transactionId + subscriberId + stepId
so worker retries update the same incident instead of duplicating it. An
explicit override wins.SECURE_AUTH_FIELDS in
encrypt-channel-connection-auth.ts so it is encrypted at rest.createSubscriberIfMissing (optional boolean on the create-endpoint
body) JIT-creates the subscriber via CreateOrUpdateSubscriberUseCase with
allowUpdate: false (create-only, never mutates). Without it, unknown
subscriberId → 404 whose message names the flag. Connect surfaces are often
the user's first Novu touchpoint, so client guides should pass true.Only start after the discovery gate has passed. Work bottom-up; each slice must build before the next (see Build order below).
A. Foundations (packages/shared, packages/stateless, libs/dal, libs/application-generic)
X_TYPE to ENDPOINT_TYPES + wire shape in ChannelEndpointByType — packages/shared/src/types/channel-endpoint.tspackages/stateless/src/lib/provider/channel-data.type.ts; add the XData type to ChannelData and to ENDPOINT_TYPES_REQUIRING_TOKENlibs/dal/src/repositories/channel-endpoint/channel-endpoint.schema.ts ({ _environmentId, subscriberId, integrationIdentifier, type }, partialFilterExpression: { type })SECURE_AUTH_FIELDS + ChannelConnectionAuth in libs/application-generic/src/encryption/encrypt-channel-connection-auth.ts (+ spec)libs/application-generic/src/schemas/channel-endpoint/channel-endpoint.schema.ts (format-validate the secret, e.g. /^[a-zA-Z0-9]{32}$/, and reject extra keys)B. Provider (packages/providers, libs/application-generic)
packages/providers/src/lib/tool/<provider>/ — resolve routing from channelData, deterministic dedup, reserved-override handling (+ spec)libs/application-generic/src/factories/tool/handlers/ — buildProvider ignores ICredentialspackages/shared/src/consts/providers/credentials/provider-credentials.ts (export const xConfig: IConfigCredential[] = []) with a comment pointing at the channel-endpoints APIC. API (apps/api/src/app/channel-endpoints)
dtos/endpoint-types.dto.ts (regex-validate the secret) + Create<X>EndpointDto in dtos/create-channel-endpoint-variants.dto.ts@ApiExtraModels + oneOf + discriminator mapping for the new DTO$in for list)e2e/create-channel-endpoint.e2e.ts: happy path, 409, rotation, createSubscriberIfMissing (404 hint / JIT create / no-mutation)D. Worker (apps/worker/src/app/workflow/usecases/send-message)
resolve-channel-endpoints.usecase.ts: extract + decrypt the secret from the connection into channelData.endpointsend-message-tool.usecase.ts: add the provider id to ENDPOINT_ROUTED_TOOL_PROVIDERS; verify SKIPPED + execution detail on missing endpoint (+ spec for the predicate)E. Surface (dashboard, docs, playground)
packages/shared/src/consts/providers/channels/tool.ts with docReference → https://docs.novu.co/platform/integrations/tool/<provider> (rebuild @novu/shared after)docs/platform/integrations/tool/<provider>.mdx + register in the Tool group of docs/docs.json — see reference.md for the required page structureplayground/nextjs mirroring the PagerDuty trio: src/lib/<provider>-endpoint-connect.ts, src/pages/api/<provider>-endpoint.ts, src/components/<provider>-end-user-connect.tsx + Clerk-gated page + SideNav entryFor per-file code patterns (index definition, provider skeleton, usecase transaction shape, DTO union, worker extraction, playground helper contract), see reference.md.
Opsgenie is credential-routed today (env-level apiKey). Refactoring it to
this model = running the checklist with opsgenie_* as the endpoint type and
removing it from the credential-routed fallback: empty opsgenieConfig,
drop the API-key read in opsgenie.handler.ts, add it to
ENDPOINT_ROUTED_TOOL_PROVIDERS. PagerDuty was never released so it had no
migration; Opsgenie may need one — check for existing integrations with
credentials before deleting the old path. The discovery gate still applies:
confirm Opsgenie's Alert API supports per-recipient API keys and idempotent
alert deduplication before assuming parity with PagerDuty.
# types flow stateless → providers → application-generic; skipping a step
# yields phantom TS errors (missing channelData on IToolOptions, etc.)
pnpm --filter @novu/shared build
pnpm --filter @novu/stateless build
pnpm --filter @novu/providers build
pnpm --filter @novu/application-generic build
# provider unit tests
CI=true pnpm --filter @novu/providers exec vitest run src/lib/tool/<provider>
# channel-endpoints e2e (from apps/api)
pnpm exec cross-env NODE_ENV=test CI_EE_TEST=true CLERK_ENABLED=true \
NODE_OPTIONS=--max_old_space_size=8192 mocha --timeout 30000 --retries 3 \
--grep '#novu-v2' --require ./swc-register.js --exit --file e2e/setup.ts \
'src/**/create-channel-endpoint.e2e{,-ee}.ts'SharedModule +
CreateOrUpdateSubscriberUseCase + UpdateSubscriber +
UpdateSubscriberChannel as providers in channel-endpoints.module.ts
(mirror channel-connections.module.ts).STORE_ENCRYPTION_KEY (32 chars) in the
env or encryptChannelConnectionAuth throws a Buffer TypeError.@novu/api SDK lags: until the OpenAPI regen runs, the internal SDK's
create-endpoint union won't include the new DTO — playground/demo code calls
the raw REST endpoint (novuFetch pattern) and swaps to the SDK later.
Never edit libs/internal-sdk by hand.integration-settings.tsx must live outside the
providerCredentials.length > 0 block or it never renders.IS_TOOL_CHANNEL_ENABLED LaunchDarkly flag — check it before debugging a
"missing provider" in the integration store.••••XXXX) — follow that convention, don't invent
server-side masking.—) in this feature's docs/UI copy; use
periods, colons, or parentheses.d921755
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.