Cloud|

App events

Declare versioned application facts and consume service events with retryable handlers.

11 min read Updated 2026-10-27 #events#capabilities#subscriptions#automation

Declare an app event when another application needs to react to a fact that has already happened. The publisher owns its event schema; each subscriber owns its handler and effects. Use a Query or Action when a caller needs an immediate answer or asks another application to do work.

Who receives an event

  • A service event has no place. Cloud delivers it to every application that subscribes to it; deploying the application approves its declared subscriptions.
  • A resource event has a place. Cloud delivers it only to a subscription that a person stands behind with a mandate, such as an automation that runs on that person's behalf. Before every delivery Cloud checks, through the reader of the place's Type, that this person may still read the place. An event never carries the rights of its publisher or of whoever caused it.
  • Applications receive service events only. An application subscription to a resource event stays blocked with resource_events_unsupported. This is the rule, not a missing feature: applications are not readers of places.

Person-mandated subscriptions are not available yet, so resource events are stored and listed in the catalog but not delivered. automation metadata does not grant authority or install a Routine.

Deployed applications are trusted parts of the installation and share the platform database and NATS credentials. Deploying an application is approval for its declared subscriptions. Core accepts an event only when its source application declared that event type and version.

Built-in resource events

Spaces and Notebooks publish these version 1 events. Each data schema is a strict empty object ({}); the envelope's subject and place carry public resource IDs accepted by the corresponding Type readers.

Full event type Subject Type Place Type When it is published
spaces.item.created spaces.item spaces.space A task, calendar event, stored occurrence override, or split series is created.
spaces.item.completed spaces.item spaces.space An existing item changes from open to completed, including a drag, calendar cancellation, or transfer into a done column.
notebooks.notebook.created notebooks.notebook notebooks.notebook A notebook is created.
notebooks.notebook.updated notebooks.notebook notebooks.notebook Notebook metadata or settings change.
notebooks.note.created notebooks.note notebooks.notebook A note is created, including welcome notes, path creation, copies, and restored copies.
notebooks.note.updated notebooks.note notebooks.notebook Persisted Markdown, title, the moved note’s parent or order, lock state, or recovery status changes, coalesced as described below.

The place Types have permission-aware readers: spaces.space.read and notebooks.notebook.read. Subject readers are spaces.item.read, notebooks.notebook.read, and notebooks.note.read. Read content through those readers; events contain no titles, text, names, email addresses, content counts, parent IDs, or previous-place references. Repeating completion or reopening an item does not publish spaces.item.completed.

A Spaces transfer that completes an item publishes under its new place only. Its source receives no resource event naming the destination. A note move carries only the note and its current notebook refs; it omits parent and position. Moving notes between notebooks remains unsupported; copying creates a new note and emits note.created in the destination notebook.

notebooks.note.updated uses trailing coalescing. A changed note records its first pending change and latest change in the domain transaction. A minute sweep publishes at most four notes, oldest pending first, when the latest change is at least 30 seconds old or the first pending change is at least five minutes old. These are eligibility thresholds; scheduler alignment and backlog can add delay. Pending changes survive restarts, including a save followed by locking the note. The sweep uses the note's current notebook and clears its marker atomically with the outbox event. A durable tick claim and row locks prevent replicas or retries from multiplying the batch limit. See the shared capacity budget.

Only the moved note is announced. Sibling position renumbering, resetting a level to automatic order, and a parent's derived hasChildren change do not mark notes. Cursor advancement, binary snapshot maintenance, rebuildable indexes, and unchanged or rejected saves do not count as content changes. Collaborative edits mark the note when persisted to its snapshot, including shared-log snapshot workers and server-side edits, rather than on each Yjs update. Existing workspace and collaborative text streams retain their own behavior.

Creates and completions remain transactional discrete facts. Notebook metadata updates also remain transactional: one update affects one notebook and does not fan out to its notes. These event rates equal the corresponding domain write rates; they have no aggregate event throttle. The domain helpers can participate in a caller-owned transaction, so they rely on the platform's one-second outbox reconciliation instead of waking before that transaction commits. The note sweep owns its transaction and calls wake() after commit.

Applications do not receive these events. They are resource events: an application subscription to them stays blocked with resource_events_unsupported, and only subscriptions a person stands behind with a mandate will receive them (see Who receives an event).

Declare events and subscriptions

Add events and subscribes to the same defineCapabilities() declaration that owns the application's Types. Event keys are local IDs; subscription keys are the publisher's full event type.

ts
import { defineCapabilities } from "@k2b/cloud";
import { z } from "zod";

const userCreated = z.object({
  userId: z.uuid(),
  provider: z.enum(["local", "ipa"]),
}).strict();

export const publisherCapabilities = defineCapabilities({
  protocolVersion: 2,
  types: {
    user: {
      title: "User",
      description: "An account in this Cloud installation.",
    },
  },
  events: {
    "user.created": {
      version: 1,
      title: "User created",
      description: "A new account was created.",
      subject: "user",
      schema: userCreated,
    },
  },
});

export const subscriberCapabilities = defineCapabilities({
  protocolVersion: 2,
  subscribes: {
    "core.user.created": {
      version: 1,
      schema: userCreated,
      async run(envelope, context) {
        console.info("User event received", {
          eventId: context.eventId,
          userId: envelope.data.userId,
        });
      },
    },
  },
});

The publisher example belongs to an app registered as core; its local user.created becomes core.user.created. The real core.user.created data contains only userId (UUID) and provider (local or ipa). Read current domain state through the owning service when a handler needs more information.

An event declaration has:

Field Contract
version Positive integer describing the data contract
title, description Human-readable meaning of the fact
subject Local Type key identifying what the event concerns
place Optional local Type key with a declared reader
schema Zod schema for the event's data
automation Optional false to exclude the event from automation discovery; the catalog defaults to true

Producer data schemas must have a closed object root and closed nested objects (for example, z.strictObject). Strings need maxLength through .max(n); UUID strings have an implicit 36-character bound. Arrays need maxItems through .max(n). Finite enums and literals are supported; schema references, recursive schemas and open records are rejected. Schemas must preserve their JSON shape. The complete envelope must fit in 4096 bytes, measured as PostgreSQL jsonb::text, including separator spaces. Startup rejects unbounded declarations and required minimum data that cannot fit with a UUID-reference envelope. A schema may still allow individual values that exceed the remaining envelope budget; publishing checks the exact complete envelope inside the transaction and rejects those values. Store large content in the owning application's durable storage and publish only the small fact needed to react.

A subscription declares the exact version, its Zod schema, and run(envelope, context). The framework parses data with that subscriber's schema before invoking the handler; envelope.data is inferred as z.output<typeof schema>. Its stable identity is <subscriber-app>:<full-event-type>@<version>, for example filesv2:core.user.created@1. Inspect the current catalog and subscription status rather than assuming that a declaration is deliverable.

Discover event declarations

cld capabilities catalog --json and --jsonl include event and subscription manifests unchanged. Each app also has an events catalog with full event, subject and place type IDs, version, title, description, automation flag, data JSON Schema and matching subscribers. Subscriber states and firstSeenAt come from Core's persisted subscription records. Publisher and subscriber discovery use the capability catalog's app access filter, including subscribers outside the current publisher page.

Administrators can use cld events ls for the persisted event catalog, including declarations of offline publishers. Its table shows the place type (— for service events) and automation flag (localized yes/no). Filter by publisher with --app <appId> and by metadata with --automation true|false; combine them to narrow the result. --json and --jsonl retain the full filtered { events: [...] } response, including schemas and subscribers.

MCP clients use two read-only tools on the Cloud MCP server:

  • cloud__events__list accepts optional appId, boolean automation, cursor, and limit (1–100, default 100). It returns { events, nextCursor? }. Each event contains appId (publisher), type, version, title, description, subject, place (full Type ID or null), and automation.
  • cloud__events__read accepts the exact { type, version } from discovery. It returns { event } with those fields plus dataSchema (JSON Schema) and subscribers (app ID, subscription ID, state, reason, and optional firstSeenAt).

MCP discovery uses the same live, hash-checked declarations and access filter as the capability catalog. It needs OAuth read or admin scope; scopes do not expand app access. Hidden apps and their subscriptions are omitted. An unknown, hidden, or stale declaration returns EVENT_NOT_FOUND on read. A catalog lookup failure returns APP_UNAVAILABLE. List pages also respect the MCP response byte limit and may contain fewer entries than requested. Pass nextCursor unchanged with the same filters until it is absent. A read whose schema and subscriber list exceed the result budget returns RESPONSE_TOO_LARGE.

These tools discover declarations only. They do not watch events, create subscriptions, or install Assistant Routines. A declared place and automation: true remain catalog metadata; they do not enable resource-event delivery or grant authority.

Read the envelope

Every delivery carries this shape:

json
{
  "id": "11111111-1111-4111-8111-111111111111",
  "type": "core.user.created",
  "version": 1,
  "source": "core",
  "time": "2026-01-01T00:00:00.000Z",
  "subject": { "type": "core.user", "id": "22222222-2222-4222-8222-222222222222" },
  "cause": { "requestId": "fixture-request" },
  "data": { "userId": "22222222-2222-4222-8222-222222222222", "provider": "local" }
}

id is a UUID, time is an ISO timestamp, and source is the publishing app ID. subject.type and optional place.type are full Type IDs. place has { type, id }; cause, when present, has only requestId. A request ID is correlation metadata, not a user's authority.

Publish in the domain transaction

Create the publisher with appEvents(capabilities) from @k2b/cloud/events, using the exact declaration object passed to defineApp({ capabilities }). Cloud binds that object to the registered app ID. Calling publish before binding fails.

publish(tx, localId, input) accepts the application's transaction, a local event key, and { subject, data, requestId? }, where subject is the subject ID. For an event with a place, also pass its ID as place. Cloud derives both Type IDs, the event type and version, and the source app from the declaration; requestId becomes cause.requestId in the envelope.

ts
import { sql } from "@k2b/cloud";
import { appEvents } from "@k2b/cloud/events";
import { publisherCapabilities } from "./capabilities";

const events = appEvents(publisherCapabilities);

export async function recordCreatedUser(userId: string, provider: "local" | "ipa") {
  await sql.begin(async (tx) => {
    // Perform the domain write in this same transaction.
    await events.publish(tx, "user.created", {
      subject: userId,
      data: { userId, provider },
    });
  });
  events.wake();
}

When importing the capability handlers would create a service dependency cycle, use a type-only import and resolve the application's platform binding lazily:

ts
import type { publisherCapabilities } from "./capabilities";
const events = appEvents<typeof publisherCapabilities>("inventory");

The app ID must match defineApp; app.start({ capabilities }) installs the binding before lifecycle startup. Publication uses that binding's real schemas, refs, and byte validation. It rejects an unknown or unstarted app before writing. Standalone service tests still require createAppEventTestHarness(appId, capabilities); there is no app-owned setter.

The committed outbox row survives a process crash. Call wake() only after the transaction commits; periodic reconciliation also picks up committed rows. Never substitute an in-memory callback for the transactional outbox.

Make handlers safe to retry

Delivery is at least once. Use the event ID and subscription identity to make effects idempotent in the subscriber's durable data. A crash after an effect and before acknowledgement can cause the same event to run again. A manual redelivery can also repeat it.

The handler context contains subscriptionId, eventId, attempt, and an abort signal. Delivery runs as the subscribing service; it does not inherit a publisher request's user authority. Keep domain permission and ownership rules in the service that performs the effect. Honor cancellation and throw when the work fails so the delivery receives a visible retry or failure state.

Subscriptions and event declarations persist while their applications are offline. Only a live subscriber's changed manifest can remove its subscription. An unreachable subscriber waits from 30 seconds up to one hour between checks without spending a handler attempt; the 30-day delivery retention bound still applies. Connection loss after sending and request timeouts spend an attempt; eight failed attempts end the delivery. The framework answers after a 30-second handler deadline and Core waits 45 seconds. Honor the signal even when a timeout has already returned: work that ignores cancellation can still finish effects.

When a live publisher removes a declaration, Core retains its history but removes it from the current catalog, blocks its subscriptions with event_not_declared, and rejects new events of that type. Offline publishers retain their declarations.

Core normally ingests the shared stream serially. A never-seen declaration gets two short registration retries (after 1 and 5 seconds), then becomes a visible rejected event with event_not_declared; later events can continue. Deterministically invalid envelopes and unusable stored schemas are also rejected without retrying ingestion. Core strips unknown top-level envelope fields, so additive metadata from a newer publisher does not block older Core instances. Administrators inspect rejection counts and metadata with cld events status; that response never includes payloads. Delivery order is Core acceptance order within a subscription and subject. A failed earlier delivery holds later deliveries for that pair. Recovered ingestion dead letters are accepted when replayed, so they can arrive after newer events; there is no global or event-time ordering guarantee.

For the retry schedule, retention budget, and recovery commands, see Operate app events.

Test a handler locally

Use appEventFixture and deliverAppEvent from @k2b/cloud/events/testing to exercise the real declaration compiler and delivery validator without a running application. A standalone declaration needs an explicit appId; declarations already bound by defineApp use that binding. Fix the event ID and time. Test schema rejection, a repeated event, and recovery after a failed effect against the subscriber's real service boundary.

ts
import { appEventFixture, deliverAppEvent } from "@k2b/cloud/events/testing";
import { publisherCapabilities, subscriberCapabilities } from "./capabilities";

const event = appEventFixture(
  publisherCapabilities,
  "user.created",
  {
    subject: "22222222-2222-4222-8222-222222222222",
    data: { userId: "22222222-2222-4222-8222-222222222222", provider: "local" },
  },
  {
    appId: "core",
    id: "11111111-1111-4111-8111-111111111111",
    time: "2026-01-01T00:00:00.000Z",
  },
);
await deliverAppEvent(subscriberCapabilities, "core.user.created", event, { appId: "filesv2" });
await deliverAppEvent(subscriberCapabilities, "core.user.created", event, { attempt: 2 });

For a terminal workflow:

sh
cld events record core.user.created --shape-only --limit 3 --out ./user-created.jsonl
cld events replay ./user-created.jsonl --handler ./src/replay-user-created.ts#handle

record requires --shape-only. --limit N inspects the last N accepted stored events of that type (at most 100), then returns one synthetic fixture per distinct shape. A shape combines version, present object keys, and array length buckets: 0, 1, 2–4, 5–16, and 17 or more. Scalar values are excluded. Fixture IDs are deterministic and distinct across shapes. Core synthesizes values from the stored declaration; this command does not export retained payload values. Unsupported synthesis returns shape_unavailable. --out creates a new file and refuses to overwrite an existing one. Without --out, text and --jsonl emit JSONL; --json emits one array.

replay reads the current catalog and validates each fixture's envelope, version, Type references, and data JSON Schema before calling the selected local function. The default export is used when #export is omitted. It passes the envelope and a delivery context with attempt 1 and subscription ID replay:<event-type>@<version>. Fixtures are processed sequentially; a failure stops replay and reports how many finished.

Replay sends no fixture to Cloud and does not invoke a production subscriber. The selected local handler controls its own effects. Its output is a count: { "delivered": 3 }.

Connect a coding agent

The Cloud skill provides compact working instructions. MCP supplies exact current documentation when details matter.

CLI command
bunx skills add https://docs.example.com

This command installs the working instructions published by this website in the selected coding agent.

For exact, current details, also connect the MCP server through one of the agent tabs.

Installation uses the open-source Vercel Skills CLI.

The skill and MCP complement each other: the skill describes workflows, while MCP supplies current documentation.