Skip to main content

Embedded Event Handlers

Using Stof for handlers that live within your policy document.

The notification system covered in Writing Custom Alert Conditions is built on top of a lower-level event mechanism that runs directly inside the policy document. Understanding it gives you more control over how events are routed, lets you register TypeScript functions callable from Stof, and lets you embed event logic directly into the policy without the Notification abstraction.


Two layers of event handling

Limitr has two layers:

Layer 1 — Attribute-decorated functions. Functions in the policy document decorated with #[meter-overage], #[meter-limit], or #[meter-changed] fire automatically on matching events. No setNotifications(), no handler registration — just a function in the document with the right attribute.

Layer 2 — App library bridge. When App is registered as a library namespace via policy.doc.lib(), event functions can call out to TypeScript. The built-in AppEvents handlers use this to route events to your addHandler() callbacks.

setNotifications() is a higher-level API that uses both layers internally. You can use either layer directly.


Attribute-decorated event handlers

Any function in the policy document decorated with a meter event attribute fires automatically when that event type occurs:

const policy = await Limitr.new(readFileSync('./policy.yaml', 'utf-8'), 'yaml');

policy.doc.parse(`
#[meter-overage]
fn handle_overage(event: obj) {
// fires on every meter-overage event
}

#[meter-limit]
fn handle_limit(event: obj) {
// fires on every meter-limit event
}

#[meter-changed]
fn handle_changed(event: obj) {
// fires on every meter-changed event
}
`);

Multiple functions can have the same attribute — all fire. The firing order follows document order. Functions can also be zero-argument:

#[meter-overage]
fn log_overage() {
// fires on overage, no event data needed
}

Registering TypeScript functions callable from Stof

policy.doc.lib() registers a TypeScript function under a namespace, making it callable from Stof using the ?Namespace.function() optional-call syntax:

policy.doc.lib('Alerts', 'notify', (customerId: string, entitlement: string, remaining: number) => {
slack.send('#alerts', `${customerId} is low on ${entitlement}: ${remaining} remaining`);
});

policy.doc.parse(`
#[meter-changed]
fn watch_usage(event: obj) {
if (event.remaining < 1000) {
?Alerts.notify(event.customer.id, event.entitlement, event.remaining);
}
}
`);

The ? prefix is Stof's optional-call operator — a no-op if Alerts.notify isn't registered rather than throwing. Safe to use when the library may not always be present.

Async support

Stof supports async natively. Async TypeScript functions registered with doc.lib() are supported and can be awaited from within Stof if needed.

Passing objects to TypeScript

TypeScript functions receive Stof values as arguments. To pass an object, serialize it to JSON first using stringify('json', event) — a Stof built-in:

policy.doc.lib('App', 'meter_overage', (json: string) => {
const event = JSON.parse(json);
billing.queueCharge(event.customer.id, event.entitlement, event.overage);
});

policy.doc.parse(`
#[meter-overage]
fn on_overage(event: obj) {
?App.meter_overage(stringify('json', event));
}
`);

Multiple namespaces

policy.doc.lib('App', 'meter_overage', (json: string) => { ... });
policy.doc.lib('App', 'meter_limit', (json: string) => { ... });
policy.doc.lib('Billing', 'queue_charge', (customerId: string, units: number) => { ... });
policy.doc.lib('Slack', 'send', (channel: string, msg: string) => { ... });

The App namespace convention

The Limitr engine's built-in event system uses the App namespace to route events to your addHandler() callbacks. When you call addHandler(), it registers App.event_handler internally:

// This is what addHandler() does internally
policy.doc.lib('App', 'event_handler', (key: string, value: unknown) => {
for (const handler of eventHandlers.values()) handler(key, value);
});

The built-in AppEvents functions call ?App.event_handler(key, json) — if App is registered, events route to your handlers. If it isn't, the call is a no-op.

You can register additional App functions alongside addHandler() without conflict:

// addHandler() sets up App.event_handler
policy.addHandler('billing', (key, value) => { ... });

// Register additional App functions for direct Stof calls
policy.doc.lib('App', 'meter_overage', (json: string) => {
// fires directly from doc.parse() Stof, bypassing addHandler()
const event = JSON.parse(json);
billing.directCharge(event.customer.id);
});

Complete example

Combining doc.lib(), doc.parse(), and addHandler():

import { Limitr } from '@formata/limitr';
import { readFileSync } from 'fs';

const policy = await Limitr.new(readFileSync('./policy.yaml', 'utf-8'), 'yaml');

// Standard App bridge — routes all events to addHandler()
policy.addHandler('main', (key: string, value: unknown) => {
if (key === 'meter-overage') {
const event = JSON.parse(value as string);
console.log('Overage:', event.customer.id, 'remaining:', event.remaining);
}
});

// Custom namespace with a specific handler
policy.doc.lib('Custom', 'example_event_handler', (userId: string, remaining: number) => {
console.log('Custom handler fired for', userId, 'remaining:', remaining);
});

// Attribute-decorated handler that calls the custom function directly
policy.doc.parse(`
#[meter-overage]
fn meter_over_limit(event: obj) {
?Custom.example_event_handler(event.customer.id, event.remaining);
}
`);

await policy.createCustomer('user_growth', 'growth');
await policy.allow('user_growth', 'chat_input', 2_100_000); // triggers overage

When meter-overage fires, both paths run:

  1. The built-in AppEvents handler calls ?App.event_handler('meter-overage', json) → routes to addHandler('main')
  2. The #[meter-overage] function fires → calls ?Custom.example_event_handler(userId, remaining)

Both paths run for every matching event. Order between attribute-decorated functions and App routing is not guaranteed.


When to use each approach

ApproachUse when
addHandler()You want TypeScript-side filtering and handling. The standard approach for billing, alerting, and operational event handling.
setNotifications()You want conditions defined in the policy, changeable without code deploys in Cloud. The right tool for threshold-based alerts.
#[attribute] + doc.parse()You want event handlers embedded directly in the document with no abstraction. Useful for routing to custom library functions with specific argument shapes.
doc.lib()You need Stof code to call specific TypeScript functions with typed arguments, not just the generic (key, json) shape of addHandler().

These approaches are additive — use as many as your application needs. A common production setup uses all four: addHandler() for billing and persistence, setNotifications() for Cloud-routed alerting, doc.lib() for domain-specific TypeScript functions, and #[attribute] handlers for logic that belongs in the document itself.