Skip to main content

A customer meeting is about to start, but the team still has to reconstruct the account context manually. The first useful step is simply knowing that a matching meeting event arrived and turning it into a focused prep request.

The runnable service centralizes experimental Triggers API authentication and headers, discovers a tenant-supported preset, registers one signed webhook delivery, and verifies the event before printing a prep request.

Call the experimental Triggers API safely

Resolve the tenant-scoped token and attach the required experimental header to every request while rejecting invalid backend origins and failed responses.

lib/glean-api.mjs
// One place that knows how to call the Glean Triggers API.
//
// The Triggers surface is experimental, so every request must carry
// `x-glean-include-experimental: true` — without it the API answers 401 with
// `Not allowed`, which reads like a credential problem and sends you looking in
// the wrong place. Keeping the header here rather than in each script means a
// new caller cannot forget it, and `verify.mjs` asserts it is still present.

import { createGleanTokenProvider } from '@gleanwork/auth';

const EXPERIMENTAL_HEADER = 'x-glean-include-experimental';

export function apiBase(env = process.env) {
const configured = (env.GLEAN_SERVER_URL || '').trim();
if (!configured) throw new Error('Set GLEAN_SERVER_URL in .env first.');
const server = new URL(configured);
if (
server.protocol !== 'https:' ||
server.username ||
server.password ||
server.search ||
server.hash ||
(server.pathname && server.pathname !== '/')
) {
throw new Error('Use a complete Glean backend HTTPS origin.');
}
return `${server.origin}/api`;
}

export async function apiHeaders(env = process.env) {
const token = await createGleanTokenProvider({
serverUrl: apiBase(env).replace(/\/api$/u, ''),
scopes: ['triggers'],
})();
return {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
[EXPERIMENTAL_HEADER]: 'true',
};
}

export async function request(path, options = {}, env = process.env) {
const response = await fetch(`${apiBase(env)}${path}`, {
headers: await apiHeaders(env),
...options,
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
// 401 on this surface usually means the OAuth grant was invalidated, not
// that the token is malformed or the scope is missing — a token can carry
// `triggers` and still be refused. Re-running the login command is the fix.
const hint =
response.status === 401
? ' — if the token looks valid, sign in again: the stored grant may have been revoked'
: '';
throw new Error(`${response.status} ${JSON.stringify(body)}${hint}`);
}
return body;
}

// The catalog is paged. Stopping at the first page would report a preset the
// deployment does serve as unavailable, which is the one thing setup must not do.
export async function allPresets(env = process.env) {
const out = [];
let cursor = '';
do {
const page = await request(
`/trigger-presets?page_size=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
{},
env,
);
out.push(...(page.results ?? []));
cursor = page.has_more ? (page.next_cursor ?? '') : '';
} while (cursor);
return out;
}

// The catalog answers in two shapes, so reading it takes two calls: the list
// resolves the configured id, and the per-preset read is the only one carrying
// `inputs`. Anything reasoning about inputs must be handed this, not a list entry.
export async function readPreset(presetId, env = process.env) {
const body = await request(
`/trigger-presets/${encodeURIComponent(presetId)}`,
{},
env,
);
if (!body.trigger_preset) {
throw new Error(
`GET /trigger-presets/${presetId} returned no trigger_preset.`,
);
}
// Identity from the id we resolved, so a narrower body than today's cannot
// register a trigger with preset_id: undefined.
return { preset_id: presetId, ...body.trigger_preset };
}

// Backward-looking: it searches indexed history, while delivery only fires
// forward from a trigger's creation. A match older than the trigger is real and
// still never arrives.
export async function searchPresetEvents(
presetId,
inputs = {},
pageSize = 5,
env = process.env,
) {
return request(
`/trigger-presets/${encodeURIComponent(presetId)}/events/search`,
{ method: 'POST', body: JSON.stringify({ inputs, page_size: pageSize }) },
env,
);
}

export async function listTriggers(env = process.env) {
const out = [];
let cursor = '';
do {
const page = await request(
`/triggers?page_size=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`,
{},
env,
);
out.push(...(page.results ?? []));
cursor = page.has_more ? (page.next_cursor ?? '') : '';
} while (cursor);
return out;
}

Discover and register one trigger

Select a supported preset, read its authoritative inputs, resolve the meeting configuration, and create a webhook delivery while saving only the returned trigger identifiers locally.

scripts/setup-trigger.mjs
#!/usr/bin/env node

import { loadEnv, writeEnv } from '../lib/config.mjs';
import { allPresets, readPreset, request } from '../lib/glean-api.mjs';
import { assertOffset, resolveInputs, selectPreset } from '../lib/presets.mjs';

loadEnv();

const webhookUrl = process.env.GLEAN_WEBHOOK_URL?.trim();
const bearer = process.env.GLEAN_WEBHOOK_BEARER_TOKEN?.trim();
const datasource = process.env.GLEAN_TRIGGER_DATASOURCE || 'googlecalendar';
const presetId = process.env.GLEAN_TRIGGER_PRESET_ID?.trim();
const offset = process.env.GLEAN_TRIGGER_OFFSET_SECONDS ?? '1800';

if (!webhookUrl || !webhookUrl.startsWith('https://')) {
throw new Error(
'Set GLEAN_WEBHOOK_URL to the public HTTPS URL ending in /webhook.',
);
}
if (!bearer) throw new Error('Set GLEAN_WEBHOOK_BEARER_TOKEN in .env.');
if (process.env.GLEAN_TRIGGER_ID) {
throw new Error(
'GLEAN_TRIGGER_ID is already set; delete it before creating another trigger.',
);
}

const listed = selectPreset(await allPresets(), presetId, {
datasource,
envVar: 'GLEAN_TRIGGER_PRESET_ID',
});
const preset = await readPreset(listed.preset_id);
const offsetSeconds = offset === 'none' || offset === '' ? undefined : offset;
assertOffset(preset, offsetSeconds);
const inputs = resolveInputs(preset, process.env, {
time_offset: offsetSeconds,
});

const body = await request('/triggers', {
method: 'POST',
body: JSON.stringify({
preset_id: preset.preset_id,
inputs,
delivery: {
webhook_url: webhookUrl,
auth: { type: 'BEARER', secret: bearer },
},
}),
});

const trigger = body.trigger;
if (!trigger?.trigger_id || !trigger.signing_secret) {
if (trigger?.trigger_id) {
await request(`/triggers/${encodeURIComponent(trigger.trigger_id)}`, {
method: 'DELETE',
});
}
throw new Error('Trigger creation returned no trigger id or signing secret.');
}

writeEnv({
GLEAN_TRIGGER_ID: trigger.trigger_id,
GLEAN_WEBHOOK_SIGNING_SECRET: trigger.signing_secret,
});
console.log(`Created ${preset.preset_id} (${preset.display_name}).`);
console.log(`Trigger ID saved to .env: ${trigger.trigger_id}`);
console.log('The signing secret is saved locally and will not be printed.');

Verify signed webhook delivery

Validate bearer delivery auth, timestamp freshness, and the Standard Webhooks signature before accepting a matching meeting event.

lib/signature.mjs
import crypto from 'node:crypto';

export const demoSecret = 'whsec_Z2xlYW4tY29va2Jvb2stbWVldGluZy1wcmVw';

function keyFor(secret) {
if (!secret.startsWith('whsec_')) {
throw new Error('Standard Webhooks secrets must start with whsec_.');
}
return Buffer.from(secret.slice(6), 'base64');
}

export function sign(secret, webhookId, timestamp, body) {
return crypto
.createHmac('sha256', keyFor(secret))
.update(`${webhookId}.${timestamp}.${body}`)
.digest('base64');
}

export function parseSignatureHeader(value = '') {
return value
.trim()
.split(/\s+/u)
.map((part) => part.split(',', 2))
.filter(([version, signature]) => version === 'v1' && signature)
.map(([, signature]) => signature);
}

export function verifySignature({
secret,
webhookId,
timestamp,
body,
signatures,
}) {
if (!secret.startsWith('whsec_')) return false;
const expected = Buffer.from(sign(secret, webhookId, timestamp, body));
return signatures.some((candidate) => {
const actual = Buffer.from(candidate);
return (
actual.length === expected.length &&
crypto.timingSafeEqual(actual, expected)
);
});
}
Customer calendarA matching customer-meeting event
Glean Platform TriggerPreset discovery, registration, and signed delivery
Prep receiverPrints a focused request without writing downstream
Node 22.12 or newer
The Platform Triggers API enabled for the tenant
A calendar trigger preset that advertises a time_offset, normally GCAL_1
A public HTTPS URL for the receiver, such as a temporary cloudflared tunnel
A bearer token that Glean may send to the receiver
1

Scaffold the recipe

npx -y tiged@2.12.8 --mode=git gleanwork/glean-cookbook/recipes/customer-meeting-prep-trigger customer-meeting-prep-trigger
2

Install dependencies

This recipe uses Node's built-in HTTP server and fetch; there is no external automation platform or downstream write dependency.

cd customer-meeting-prep-trigger && npm install
3

Run the fixture verification

Verify preset selection, experimental Platform API headers, input resolution, and Standard Webhooks signing before connecting a tenant.

npm run verify:fixture
4

Sign in to Glean

Use @gleanwork/auth for tenant discovery and refreshable OAuth credentials stored outside the project. If OAuth is unavailable, provide a user-scoped token with the Triggers permission as a CI fallback.

npm run login -- --email "<work-email>"
5

Configure the meeting pattern and receiver

Set GLEAN_TRIGGER_INPUT_TITLE to a distinctive title substring such as Customer QBR, choose the preset ID returned by the catalog, and set GLEAN_WEBHOOK_URL to a public HTTPS URL ending in /webhook. Set a bearer token that the receiver will accept.

6

Start and expose the receiver

The receiver listens on loopback, verifies bearer delivery auth and the Standard Webhooks signature, and prints a prep request for matching meeting titles. Expose /webhook through a public HTTPS tunnel before registering the trigger.

npm start
7

Register the Platform Trigger

Setup reads GET /api/trigger-presets and the selected preset detail, then creates one trigger through POST /api/triggers. It saves the returned trigger ID and signing secret locally.

npm run setup
8

Test one signed delivery

Send one fixture delivery through the same signed webhook contract without waiting for a calendar event. Then schedule one real matching meeting to verify forward delivery from the Platform Trigger.

npm run test:webhook
9

Delete the trigger

Delete only the trigger ID created by this recipe and clear the local signing secret.

npm run delete

The quickstart creates one trigger and prints one prep request; it does not connect a tracker, Slack, Cursor, or n8n.

The receiver verifies bearer auth, timestamp freshness, and the Standard Webhooks signature before inspecting the event.

Glean cannot deliver to localhost directly. Use a temporary HTTPS tunnel for the beginner walkthrough and keep the URL private.

The recipe refuses to guess a preset ID and only registers a preset returned by the connected tenant's catalog.

Take it further
  • Pass the meeting and account context to the Triage or Account Brief Agent after the delivery contract is reliable.
  • Add a read-only preview that gathers recent account changes before writing anything downstream.
  • Use the same receiver pattern with a different tenant-supported content-event preset.

Deliver one signed event for a customer meeting whose title matches the configured pattern.

The receiver verifies the bearer token and Standard Webhooks signature, prints a Customer meeting prep request with the title, start time, and event URL, and does not call any downstream system.

Delete the trigger after the test.

The recipe deletes only the trigger ID saved by its own setup command and clears the local signing secret.

View source

Runs the recipe through the Glean cookbook plugin.

Auth

Run the authenticate step on this page. It discovers your tenant from work email and signs you in with OAuth, using the shipped login command. If OAuth is unavailable, create a scoped Glean-issued token in Token Management (TRIGGERS).

At a glance
CapabilitiesWorkflows
SurfacesPlatform API
StatusQuickstart
Time~20 min
Required scopes
TRIGGERS