Skip to content

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.

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.

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.

A single event fires for many occurrences, but a given subscriber usually cares about only some of them. The filter / check pair expresses that:

  • filter is a schema describing which occurrences a subscriber cares about — a conversation id, a sender, a device. A subscriber supplies a value matching this schema.
  • check is the predicate the runtime evaluates per occurrence. It receives { payload, input } — the occurrence and the subscriber’s filter value — and returns true when 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.

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 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 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 filter means no filtering. check is 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.

  • 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 publish enforces it; version changes deliberately.
  • Keep check pure and cheap. It runs per filtered subscriber per occurrence — do the heavy work in the handler.
  • Put every filterable field in the payload. check compares the two; a filter on something the payload does not carry cannot be implemented.
  • Always pass an AbortSignal for 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.