.cursor/skills/nv-endpoint-routed-tool-provider/reference.md
All snippets are distilled from the live pagerduty_service code. Substitute
your endpoint type / secret fields. Read the actual PagerDuty files alongside
this — they are the source of truth.
packages/shared/src/types/channel-endpoint.ts)export const ENDPOINT_TYPES = {
// ...
PAGERDUTY_SERVICE: 'pagerduty_service',
} as const;
export type ChannelEndpointByType = {
// ...
/**
* At the API boundary this is the wire shape on both writes and reads.
* Internally `routingKey` is persisted encrypted on ChannelEndpoint.endpoint;
* `region` stays plaintext. No ChannelConnection is involved (connections
* are OAuth-only).
*/
[ENDPOINT_TYPES.PAGERDUTY_SERVICE]: { routingKey: string; region: 'us' | 'eu' };
};
Mirror in packages/stateless/src/lib/provider/channel-data.type.ts (own copy
of ENDPOINT_TYPES there), add the XData member to the ChannelData union,
and append to ENDPOINT_TYPES_REQUIRING_TOKEN.
libs/dal/.../channel-endpoint.schema.ts)channelEndpointSchema.index(
{ _environmentId: 1, subscriberId: 1, integrationIdentifier: 1, type: 1 },
{
name: 'unique_pagerduty_service_per_subscriber_integration',
unique: true,
partialFilterExpression: { type: ENDPOINT_TYPES.PAGERDUTY_SERVICE },
}
);
libs/application-generic/src/encryption/)Sensitive endpoint fields encrypt via encryptChannelEndpoint (and decrypt on
read). Non-secret companions (e.g. region, method) stay plaintext. Spec:
assert the encrypted value starts with the novu mask prefix and round-trips
through the matching decrypt helper. Do not create a synthetic
ChannelConnection for tool routing secrets.
packages/providers/src/lib/tool/<provider>/)Key shape (see pagerduty.provider.ts for the full version):
export class PagerDutyProvider extends BaseProvider implements IToolProvider {
async sendMessage(options: IToolOptions, bridgeProviderData = {}) {
const { routingKey, region } = this.resolveRouting(options); // throws if bad
// ... build payload; dedup below
}
private resolveRouting(options: IToolOptions) {
const { channelData } = options;
if (!channelData || !isChannelDataOfType(channelData, ENDPOINT_TYPES.PAGERDUTY_SERVICE)) {
throw new Error('PagerDutyProvider requires channelData of type "pagerduty_service" ...');
}
return channelData.endpoint;
}
private resolveDedupKey(override: unknown, options: IToolOptions) {
if (typeof override === 'string' && override) return override;
const { transactionId, subscriberId, stepId } = options;
if (!transactionId || !subscriberId || !stepId) return undefined;
// deterministic hash of the three ids
}
}
IToolOptions (stateless) carries channelData, transactionId,
subscriberId, stepId. The handler's buildProvider(_: ICredentials) just
does this.provider = new XProvider(). Keep a RESERVED_OVERRIDE_KEYS set so
step overrides can't collide with structural payload fields.
apps/api/src/app/channel-endpoints/dtos/)endpoint-types.dto.ts: wire-shape DTO with @Matches(/^[a-zA-Z0-9]{32}$/)
on the secret and @IsIn([...]) on enums.create-channel-endpoint-variants.dto.ts: Create<X>EndpointDto extends CreateChannelEndpointBaseDto with @IsEnum([ENDPOINT_TYPES.X]) type and
@ValidateNested() @Type(() => XEndpointDto) endpoint. The base DTO already
has subscriberId (required) and createSubscriberIfMissing? (optional).@ApiExtraModels, the create DTO to the
oneOf + discriminator.mapping of @ApiBody, and the wire DTO to the
response oneOf.// persist endpoint directly; no ChannelConnection
if (command.type === ENDPOINT_TYPES.PAGERDUTY_SERVICE) {
return await this.createPagerDutyEndpoint(command, identifier, integration, contextKeys);
}
// inside createPagerDutyEndpoint:
// endpoint = channelEndpointRepository.create({
// ..., endpoint: encryptChannelEndpoint({ routingKey, region })
// // encrypts routingKey only; region stays plaintext
// })
// return { ...endpoint, endpoint: { routingKey, region } } // decrypted wire shape
// catch: duplicate-key (code 11000) -> ConflictException(409)
assertSubscriberExists: if missing and createSubscriberIfMissing is falsy,
throw 404 whose message names the flag; otherwise
CreateOrUpdateSubscriberUseCase.execute({ ..., allowUpdate: false }).
Update usecase: re-encrypt rotated secrets on the endpoint document, bump
updatedAt, return the decrypted wire shape. Delete usecase: delete the
endpoint document (no connection cascade). Get/list: decrypt sensitive fields
on the endpoint for the response.
resolve-channel-endpoints.usecase.ts — in extractToken:
if (endpoint.type === ENDPOINT_TYPES.PAGERDUTY_SERVICE) {
// decrypt ChannelEndpoint.endpoint, return { endpoint: { routingKey, region } }
return this.extractPagerDutyEndpoint(endpoint);
}
send-message-tool.usecase.ts:
export const ENDPOINT_ROUTED_TOOL_PROVIDERS = new Set<string>([ToolProviderIdEnum.PagerDuty]);
// send loop: no channelData for an endpoint-routed provider ->
// emit execution detail + status SKIPPED, continue.
// credential-routed providers keep the legacy integration.credentials send.
// Pass channelData + transactionId/subscriberId/stepId into provider.send.
docs/platform/integrations/tool/<provider>.mdx)Mintlify MDX; follow docs/AGENTS.md. Required structure (PagerDuty page is
the template):
title: "{Provider} Tool Integration with Novu",
sidebarTitle, description.<Note>: no env-level secret; missing endpoint → step skipped.<Steps> + <Warning>.<Warning>, mermaid
sequence diagram (browser → customer backend → Novu API), create example in
the full SDK tab order (Node.js, Python, Go, PHP, .NET, Java, cURL),
endpoint-shape table, 409/PATCH rotation, read+mask, DELETE of the endpoint
(encrypted fields live on the endpoint; no connection cascade).<Steps> + trigger tab set
<Columns> cards to
/api-reference/channel-endpoints/*.Register under the Tool group in docs/docs.json, then point the provider's
docReference in packages/shared/.../channels/tool.ts at
https://docs.novu.co/platform/integrations/tool/<provider>${UTM_CAMPAIGN_QUERY_PARAM}
and rebuild @novu/shared.
playground/nextjs)src/lib/<provider>-endpoint-connect.ts: server-only helper over raw REST
(novuFetch with NOVU_SECRET_KEY). Contract: ensureXEndpoint (POST with
createSubscriberIfMissing: true; on 409, list → PATCH = idempotent
rotate), listXEndpoints, deleteXEndpoint, client-side format validator.src/pages/api/<provider>-endpoint.ts: POST/GET/DELETE route = the "acme
backend"; validates body, resolves integration identifier from body or
NOVU_CONNECT_<PROVIDER>_INTEGRATION_IDENTIFIER.src/components/<provider>-end-user-connect.tsx: secret form (useId for
input ids, client-side masking ••••XXXX), endpoint list with disconnect,
and a trigger-workflow section posting to the shared /api/trigger-event
proxy with to: { subscriberId }.src/pages/connect-<provider>-end-user/index.tsx using
user.id as the subscriberId, plus a SideNav link and
NEXT_PUBLIC_CONNECT_<PROVIDER>_* entries in .env.example.Playground code is demo-only: never copy it into production apps/packages.