Webhooks, queue messages, and internal events all need to run agent logic outside of a synchronous HTTP response. Anvil’s defineTrigger and TriggerRegistry give you a named, observable handler for any event source. Receive a webhook, validate it, and fire a trigger — the rest runs with the same tracing and observability machinery as your request-driven agents.
Defining a trigger
Create a trigger.ts file in your background tasks directory and export a defineTrigger descriptor as the default export:
import { defineTrigger } from 'anvil/schedule';
export default defineTrigger({
name: 'order-placed',
async run(ctx) {
const order = ctx.payload as Order;
console.log('Processing order', order.id);
await processOrder(order);
},
});
defineTrigger is a thin identity function that types the descriptor. Registration happens via TriggerRegistry.
TriggerTask fields
| Field | Type | Description |
|---|
name | string | Unique trigger identifier. Must match the name passed to registry.fire() |
run | (ctx: TaskContext) => unknown | Promise<unknown> | The handler function. Receives the event payload via ctx.payload |
TaskContext
Every run function receives a TaskContext:
| Field | Type | Description |
|---|
now | Date | The time the trigger fired (defaults to new Date() if not provided to fire()) |
payload | unknown | The event payload passed to registry.fire(). Cast it to your expected type |
trace | TraceHandle | undefined | An open trace span for this run, if a tracer is configured on the registry |
TriggerRegistry
TriggerRegistry holds named trigger handlers and fires them on demand.
Creating a registry
import { TriggerRegistry } from 'anvil/schedule';
import { tracer } from './observability.js'; // optional
const triggers = new TriggerRegistry({
tracer, // optional: wraps each run in a trace
});
registry.register(task)
Register a TriggerTask with the registry. Returns this for chaining.
import orderPlaced from './tasks/orders/trigger.js';
import userSignedUp from './tasks/users/trigger.js';
triggers.register(orderPlaced).register(userSignedUp);
registry.fire(name, payload?, now?)
Fire a named trigger, passing an optional payload and optional firing time. Returns a Promise that resolves with the handler’s return value.
await triggers.fire('order-placed', {
id: 'ord_123',
items: [{ sku: 'WIDGET-A', qty: 2 }],
total: 49.99,
});
Throws an error if no trigger is registered under name.
registry.has(name)
Check whether a trigger is registered before firing. Useful in webhook routers that dispatch to different triggers.
if (triggers.has(eventType)) {
await triggers.fire(eventType, event.data);
} else {
logger.warn({ eventType }, 'No trigger registered for event type');
}
Integrating with a webhook route
The typical pattern is: receive the webhook → validate the signature → fire the trigger.
import { createApp } from 'anvil';
import { TriggerRegistry } from 'anvil/schedule';
const app = createApp();
const triggers = new TriggerRegistry({ tracer });
// Register your trigger handlers
import orderPlaced from './tasks/orders/trigger.js';
triggers.register(orderPlaced);
// Expose a webhook endpoint
app.post('/webhooks/stripe', async (ctx) => {
const signature = ctx.headers.get('stripe-signature') ?? '';
const rawBody = await ctx.text();
// Validate the webhook signature
const event = stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET);
// Route to the right trigger by event type
const triggerName = stripeEventToTriggerName(event.type); // e.g. 'order-placed'
if (triggers.has(triggerName)) {
// Fire and forget — respond 200 immediately, process async
void triggers.fire(triggerName, event.data.object);
}
return ctx.json({ received: true });
});
registry.fire() runs the handler in the same async context as the caller. If you want fire-and-forget behaviour (respond to the webhook immediately), use void triggers.fire(...) and do not await it. For guaranteed delivery, use a durable queue and fire triggers from a queue consumer.
Auto-discovering triggers with loadBackgroundTasks
loadBackgroundTasks(dir) discovers both schedule.ts and trigger.ts files in a single pass. Use it to register all triggers alongside scheduled tasks:
import { loadBackgroundTasks, TriggerRegistry, Scheduler } from 'anvil/schedule';
const { schedules, triggers: triggerTasks } = await loadBackgroundTasks('./src/tasks');
const scheduler = new Scheduler({ tracer });
for (const task of schedules) scheduler.add(task);
const triggers = new TriggerRegistry({ tracer });
for (const task of triggerTasks) triggers.register(task);
loadBackgroundTasks recursively walks the directory, finds files named trigger.ts (or .js, .mts, .mjs), imports their default export, and returns them in the triggers array. The task’s name defaults to its folder path if the descriptor’s name is empty.
Co-locate each trigger with the feature it belongs to:
src/tasks/orders/trigger.ts, src/tasks/users/trigger.ts. loadBackgroundTasks finds them all automatically.
Full example: processing order events
Define the trigger handler
// src/tasks/orders/trigger.ts
import { defineTrigger } from 'anvil/schedule';
import { fulfillOrder } from '../../orders/fulfillment.js';
interface Order {
id: string;
items: Array<{ sku: string; qty: number }>;
customerId: string;
}
export default defineTrigger({
name: 'order-placed',
async run(ctx) {
const order = ctx.payload as Order;
await fulfillOrder(order, ctx.trace);
},
});
Register the trigger at startup
// src/server.ts
import { loadBackgroundTasks, TriggerRegistry } from 'anvil/schedule';
const { triggers: triggerTasks } = await loadBackgroundTasks('./src/tasks');
const triggers = new TriggerRegistry({ tracer });
for (const task of triggerTasks) triggers.register(task);
// Make the registry available to webhook routes
export { triggers };
Fire the trigger from your webhook endpoint
// src/routes/webhooks.ts
import { triggers } from '../server.js';
app.post('/webhooks/orders', async (ctx) => {
const body = await ctx.json<{ event: string; data: unknown }>();
if (body.event === 'order.placed') {
void triggers.fire('order-placed', body.data);
}
return ctx.json({ ok: true });
});
Observability
Each registry.fire() call opens a trace span named trigger <name> when a tracer is configured. The span records success or failure and is visible in the Anvil dashboard alongside your HTTP request traces, giving you a unified view of all agent activity — request-driven and event-driven alike.