Publish events
An event is a signal a provider emits so automations can react to something happening: a message arrived, a device changed state. Where an action waits to be called, an event pushes a notification outward. This guide covers defining an event, registering it, and the publish/subscribe API.
Define an event
Section titled “Define an event”Use defineEvent. An event needs a stable id, a human-readable name and description,
a payload schema, and a filter / check pair.
import { defineEvent, z } from '@grundlag/core';
const messageReceived = defineEvent({ id: 'messageReceived', name: 'Message received', description: 'Emitted when a new message arrives in a conversation.', payload: z.object({ messageId: z.string(), conversationId: z.string(), senderId: z.string(), }), filter: z.object({ conversationId: z.string().optional(), senderId: z.string().optional(), }), check: ({ payload, input }) => { // `payload` is the occurrence that just happened; `input` is the filter value // a subscriber supplied. Return true when the two match. An unset filter // field means "don't care". return ( (input.conversationId === undefined || input.conversationId === payload.conversationId) && (input.senderId === undefined || input.senderId === payload.senderId) ); },});z is re-exported from the core package, so you do not need a separate zod import.
The payload is a public contract
Section titled “The payload is a public contract”payload is the schema of the data each occurrence carries. It is part of your provider’s
public surface: subscribers type their handlers against it, so changing it breaks them the
same way changing an action’s output would. Treat it as a contract you version
deliberately.
Payloads should carry ids and metadata, not large embedded objects. Emit
{ messageId, conversationId, senderId }, not the full message body with attachments. A
subscriber that needs the full record fetches it — usually through the matching
entity — using the ids you handed it. This keeps
payloads small, keeps them stable as the underlying record grows, and avoids shipping stale
snapshots.
Because it is a contract, it is enforced: publish validates against the schema and hands
subscribers the parsed value, the same way defineAction validates an action’s output. So
a payload that has drifted from its schema throws an EventPayloadValidationError instead of
quietly delivering the wrong shape, and a field you did not declare is stripped rather than
becoming something a subscriber comes to depend on.
The filter / check model
Section titled “The filter / check model”A single event fires for many occurrences, but a given subscriber usually cares about only
some of them. The filter / check pair expresses that:
filteris a schema describing which occurrences a subscriber cares about — a conversation id, a sender, a device. A subscriber supplies a value matching this schema.checkis the predicate the runtime evaluates per occurrence. It receives{ payload, input }— the occurrence and the subscriber’s filter value — and returnstruewhen they match.
Every field a filter can narrow on must appear in the payload. check has nothing
else to compare against, so a filter on areaId is unimplementable unless the payload
carries areaId. It is worth designing the two together.
The conventional shape is that an unset filter field means “don’t care”, so an empty filter
matches everything and each field a subscriber sets narrows it further. Note that a payload
usually says null for “not in a project” while a filter says undefined for “any
project” — compare against both, or a filtered subscription will quietly match nothing:
const matches = (wanted: string | undefined, actual: string | null): boolean => wanted === undefined || wanted === actual;Keep check pure and fast: it runs for every filtered subscriber on every occurrence. It is
handed the payload and the filter and nothing else — no Services, deliberately. If a
decision needs more than the occurrence itself, close over what you need where the event is
defined rather than reaching for it per occurrence.
Register the event
Section titled “Register the event”An event becomes publishable once it is registered on a ProviderInstance:
provider.events.register(messageReceived);register accepts one or more events and keys them by id.
Publish
Section titled “Publish”Publish an occurrence with events.publish. The payload is typed by the event’s payload
schema, and checked against it at runtime:
provider.events.publish(messageReceived, { messageId: 'm_123', conversationId: 'c_9', senderId: 'u_42',});Validation happens before any subscriber is notified, so an invalid payload reaches none of them rather than some. The throw surfaces wherever you published — typically inside an action, after its effect has already happened, exactly as an output-validation failure does. Publish last, once the work has succeeded, and keep the payload built from values you already have.
events.emitter.emit bypasses all of this. It is the escape hatch for wiring event
forwarding, not a way to publish.
Providers typically publish from wherever they observe the change — a poller, a websocket handler, or inside an action after it causes an effect.
Subscribe
Section titled “Subscribe”Subscribe with events.subscribe, passing the event, a listener typed by the payload, and
an AbortSignal for cleanup:
const controller = new AbortController();
provider.events.subscribe( messageReceived, (payload) => { // payload is typed as { messageId, conversationId, senderId } handleIncoming(payload.messageId); }, controller.signal,);
// Later, tear the subscription down:controller.abort();When the AbortSignal fires, the subscription is removed. Always pass a signal for
subscriptions with a bounded lifetime (a session, a request, a running action) so they do
not leak.
To receive only some occurrences, pass an options object with a filter instead of a bare
signal. Each occurrence is then offered to the event’s check, and only what it accepts
reaches the listener:
provider.events.subscribe(messageReceived, (payload) => handleIncoming(payload.messageId), { filter: { conversationId: 'c_9' }, abortSignal: controller.signal,});The filter is validated against the event’s filter schema when you subscribe, and check
receives the parsed value — so a filter that could never match throws an
EventFilterValidationError up front rather than becoming a subscription that silently
receives nothing. Two things follow from where filtering happens:
- Omitting
filtermeans no filtering.checkis not consulted, and the listener receives every occurrence. - Each subscriber gets its own filter. They do not interact; the same event can feed one narrow subscription and one broad one.
The registry also exposes events.list(), events.get(id), and the underlying
events.emitter for advanced cases. The wildcard event channel that emitter exposes is
never filtered — it is how a host observes everything a provider emits.
Guidelines
Section titled “Guidelines”- Payloads carry ids and metadata, not big objects. Let subscribers fetch the full record by id through the matching entity.
- Treat the payload schema as a public contract. Subscribers type against it, and
publishenforces it; version changes deliberately. - Keep
checkpure and cheap. It runs per filtered subscriber per occurrence — do the heavy work in the handler. - Put every filterable field in the payload.
checkcompares the two; a filter on something the payload does not carry cannot be implemented. - Always pass an
AbortSignalfor any subscription that should not outlive its owner. - Publish from where the change is observed — a poller, a socket handler, or after an action’s effect.