Integrate with an AI Assistant
Integrating Canopy with an AI coding assistant? Point it at the machine-readable surfaces below, and settle the one decision the endpoint catalog cannot make for it before it writes sign-in code.
Four decisions before any code
The endpoint catalog cannot make these for you, and each one changes what the code below looks like. Settle them with the person you are working for, then write to them.
Pick a session mode
Provisioning an Environment over the API (the API Walkthrough) is a server-to-server job and needs no decision here. Signing your end users in does, and it is the one part that cannot be inferred from the endpoint catalog, because every mode calls the same endpoints:
Decide this before you write sign-in code
This is worth deciding before you write sign-in code rather than after. Nothing here fails at build time: curl and a same-origin dev server behave perfectly. A third-party refresh cookie only breaks in a real browser on your production domain, where the user is signed out as soon as the access token lapses.
Machine-readable surfaces
Point your agent at these. Everything the docs describe is also published in formats an agent can fetch directly, with no browser and no rendering:
Don't guess documentation URLs
Endpoint pages live at /docs/api-reference/{operationId}, for example /docs/api-reference/ApiPermissionsController_evaluate. The ids come from the OpenAPI documents; resource-name URLs like /docs/api-reference/permissions do not exist.
The code, as the skill tests it
Each block is quoted from the Canopy agent skill, where it is type-checked against the published packages on every release. Copy it rather than writing an equivalent; the status mapping in it is the part assistants get wrong on their own.
Verify a token
One verifier per process, named for the Environment, with audience set for Hosted Login and left out for the Direct API. Never decode a token.
import { Module } from "@nestjs/common";
import { CanopyModule } from "@canopy-io/nestjs";
interface AuthedRequest {
canopyToken?: { sub: string };
params?: Record<string, string>;
}
@Module({
imports: [
CanopyModule.forRoot<AuthedRequest>({
apiKey: process.env.CANOPY_API_KEY,
verify: {
environmentId: process.env.CANOPY_ENVIRONMENT_ID ?? "",
// Hosted Login: your OAuth client id. Direct API: leave this out.
audience: process.env.CANOPY_OAUTH_CLIENT_ID,
},
resolveIdentity: (request) => request.canopyToken?.sub,
resolveNode: (request) => request.params?.["nodeId"],
}),
],
})
export class AppModule {}import {
TokenVerifier,
tokenErrorStatus,
type CanopyTokenClaims,
} from "@canopy-io/node";
// One verifier per process: it caches the key set. It accepts identity tokens
// only (your end users); admin, staff and API-key tokens are refused.
const verifier = new TokenVerifier({
environmentId: process.env.CANOPY_ENVIRONMENT_ID ?? "",
// Hosted Login: your OAuth client id. Direct API: leave this out.
audience: process.env.CANOPY_OAUTH_CLIENT_ID,
});
export async function authenticate(
authorization: string | undefined,
): Promise<CanopyTokenClaims | null> {
const token = authorization?.startsWith("Bearer ")
? authorization.slice("Bearer ".length)
: undefined;
if (!token) {
return null;
}
try {
return await verifier.verify(token);
} catch (error) {
// 401 for the token's own faults (expired, bad signature, wrong issuer),
// 503 when the keys could not be read. error.code is for your logs.
if (tokenErrorStatus(error).status === 401) {
return null;
}
throw error;
}
}Guard a route
Authenticate, then ask whether the identity holds the permission at the node the route names. A denial is 403, an unreachable Canopy is 503, a broken integration is 500; never collapse them.
import { Controller, Get, Post, UseGuards } from "@nestjs/common";
import {
CanopyGuard,
CanopyTokenGuard,
RequirePermission,
} from "@canopy-io/nestjs";
@Controller("nodes/:nodeId/reports")
@UseGuards(CanopyTokenGuard, CanopyGuard)
export class ReportsController {
// Does the identity hold reports.view at :nodeId, or above it?
@Get()
@RequirePermission("reports.view")
list() {
return [];
}
@Post(":reportId/publish")
@RequirePermission("reports.publish")
publish() {
return { published: true };
}
// Anywhere in the Environment: for showing a menu item, never for a
// resource that belongs to a node.
@Get("menu")
@RequirePermission("reports.view", { scope: "app_wide" })
menu() {
return { showReports: true };
}
}import express, {
type NextFunction,
type Request,
type Response,
} from "express";
import {
Canopy,
LocalAuthorizer,
TokenVerifier,
authorizationErrorStatus,
tokenErrorStatus,
type CanopyTokenClaims,
} from "@canopy-io/node";
const canopy = new Canopy({ apiKey: process.env.CANOPY_API_KEY });
const authorizer = new LocalAuthorizer(canopy.client);
const verifier = new TokenVerifier({
environmentId: process.env.CANOPY_ENVIRONMENT_ID ?? "",
// Hosted Login: your OAuth client id. Direct API: leave this out.
audience: process.env.CANOPY_OAUTH_CLIENT_ID,
});
interface CanopyLocals {
claims?: CanopyTokenClaims;
}
// 1. Authenticate: verify the bearer token, never decode it.
export async function authenticate(
request: Request,
response: Response,
next: NextFunction,
) {
const header = request.headers.authorization;
const token = header?.startsWith("Bearer ")
? header.slice("Bearer ".length)
: undefined;
if (!token) {
response.sendStatus(401);
return;
}
try {
// Identity tokens only, by default: admin and staff tokens are refused.
(response.locals as CanopyLocals).claims = await verifier.verify(token);
next();
} catch (error) {
// 401 when the token is at fault, 503 when the keys could not be read.
response.sendStatus(tokenErrorStatus(error).status);
}
}
// 2. Authorize: does the identity hold `permission` at the node `nodeOf` names?
export function requirePermission(
permission: string,
nodeOf: (request: Request) => unknown,
) {
return async (request: Request, response: Response, next: NextFunction) => {
const claims = (response.locals as CanopyLocals).claims;
const nodeId = nodeOf(request);
// No node, or not a single id: denied, never widened to app_wide.
if (!claims || typeof nodeId !== "string" || nodeId === "") {
response.sendStatus(403);
return;
}
try {
const { allowed } = await authorizer.evaluate({
identity_id: claims.sub,
permission,
node_id: nodeId,
});
if (allowed) {
next();
return;
}
response.sendStatus(403);
} catch (error) {
// The mapping CanopyGuard uses. Never collapse these into one 403.
response.sendStatus(authorizationErrorStatus(error).status);
}
};
}
const app = express();
app.get(
"/nodes/:nodeId/reports",
authenticate,
requirePermission("reports.view", (request) => request.params["nodeId"]),
(_request, response) => {
response.json([]);
},
);
export default app;Handle a webhook
Verify the signature over the raw bytes, then hand the delivery to the authorizer so a revocation reaches the next check without waiting out the cache. The same handler serves NestJS on a route that keeps the raw body.
import express from "express";
import {
isWebhookEvent,
LocalAuthorizer,
verifyWebhookSignature,
type WebhookDelivery,
} from "@canopy-io/node";
declare const app: express.Express;
declare const authorizer: LocalAuthorizer;
declare const webhookSecret: string;
app.post(
"/webhooks/canopy",
express.raw({ type: "application/json" }),
async (req, res) => {
const valid = await verifyWebhookSignature({
headers: req.headers,
rawBody: req.body as Buffer,
secret: webhookSecret,
});
if (!valid) {
res.sendStatus(401);
return;
}
const delivery = JSON.parse(
(req.body as Buffer).toString(),
) as WebhookDelivery;
authorizer.applyEvent(delivery);
if (isWebhookEvent(delivery, "organization.member.added")) {
const { identity_id, role_id } = delivery.data.metadata;
console.log(identity_id, role_id);
}
res.sendStatus(204);
},
);Never
The mistakes the skill tells an assistant to look for, in its words. Each one has a page in the skill's mistakes.md.
Where to go next
Tell us how we can improve this guide.