# Package subscriptions and events

Use package subscriptions when a saved package should react to Kody-owned event
topics. The saved package remains the top-level entity; subscriptions are nested
manifest metadata and package runtime handlers.

## Manifest shape

Declare subscriptions in `package.json#kody.subscriptions` as a record keyed by
event topic:

```json
{
	"name": "@scope/email-automation",
	"exports": {
		".": "./src/index.ts"
	},
	"kody": {
		"description": "Automates stored inbound email.",
		"subscriptions": {
			"email.message.received": {
				"handler": "./src/on-email-message-received.ts",
				"description": "Process stored inbound mail."
			}
		}
	}
}
```

Each subscription definition supports:

- `handler` (required): package-local module path for the event handler.
- `description` (optional): human-readable purpose for package detail and
  subscription listings.
- `filters` (optional): topic-specific metadata reserved for dispatchers.

Package checks normalize handler paths and build published bundle artifacts for
subscription handlers. Runtime dispatch invokes the handler through the normal
package execution path with package context, package-owned storage,
package-owned secrets, and `kody:runtime`.

## Discovery

Use `search` for package subscription work, then call the built-in
`packageSubscriptionsList` capability to inspect the signed-in user's declared
subscriptions:

```json
{
	"topic": "email.message.received"
}
```

The result lists scoped package `name`, `package_id`, topic, handler,
description, and filters. Use this before debugging event dispatch, building
fan-out, or deciding whether a package already subscribes to a topic.

## Synthetic dispatch

`packageSubscriptionDispatch` is the **interactive MCP** post-publish smoke test
for **one** declared subscription handler on **one** owner-scoped saved package.
It is a platform-marked real-surface run with real side effects. Call it after
publish to verify handler wiring without waiting for production fan-out.

Package reuse is a static `import … from 'kody:@scope/pkg/export'` (declare
`kody.dependencies`). See [Package reuse](https://github.com/kentcdodds/kody/blob/main/docs/use/packages.md#package-reuse).

Reuse the scoped `name` from `packageSubscriptionsList` as leftover `kody_id`.

```json
{
	"kody_id": "@owner/email-automation",
	"topic": "email.message.received",
	"params": {}
}
```

For stored inbound mail, replay with `email_message_id` instead of `params`:

```json
{
	"kody_id": "@owner/email-automation",
	"topic": "email.message.received",
	"email_message_id": "00000000000000000000000000000001"
}
```

Pass exactly one of `params` or `email_message_id`. There is no caller
`idempotency_key` — the platform generates internal idempotency keys.

Final `published` and `already_published` results from
`packagePublishExternalPush` include `test_hints.subscriptions[]` with a starter
snippet per declared topic when subscriptions are present. For a `dispatched`
result, poll the workflow to completion before reading its final publish result.
Failed and non-fast-forward results have no test hints.

### Handler guidance

- **Platform markers.** The platform sets top-level `synthetic: true` and, for
  stored-mail replay, `replay_of`. Real event dispatch strips caller-supplied
  `synthetic` and `replay_of` from handler envelopes. Run records agree with the
  handler payload.
- **`params` or `email_message_id`.** Fixture `params` merge into the handler
  envelope before markers are added. `email_message_id` rebuilds the stored
  inbound email envelope from D1.
- **Treat synthetic identically to production.** Handlers run the same code path
  unless a deliberately visible irreversible-side-effect guard says otherwise.
- **Start minimal.** Begin with `{}` or the smallest object your handler
  accepts, then add fields until the smoke test covers the branches you care
  about.
- **Filters are not applied.** Production dispatch for package-emitted topics
  skips subscribers when `filters` do not match the payload; synthetic dispatch
  always runs the named package. Put filter-matching fields inside `params` when
  testing filter-dependent code paths.
- **Admin-only topics** gate **production** fan-out on admin role; synthetic
  dispatch still runs your handler directly for smoke testing. Signed-in admins
  can read those topics in the Admin docs section.
- **Activity.** Synthetic runs appear on the `subscription` surface. Handler
  failures do not emit `run.error.recorded` (recursion guard).

Full call semantics and examples:
[Synthetic event dispatch](https://github.com/kentcdodds/kody/blob/main/docs/use/synthetic-event-dispatch.md).

## Package-emitted topics (`@scope/...`)

Packages can define their own event topics and emit to them; every other package
saved by the same user that declares the topic in `kody.subscriptions` receives
the event. There is no cross-user delivery.

### Declaring emitted topics

Declare topics in `package.json#kody.emits`. Topics must use the scoped form
`@{username}/topic.name` with a lower-dot-case body, and the scope must match
the emitting package's npm scope:

```json
{
	"name": "@kentcdodds/discord-gateway",
	"kody": {
		"description": "Discord gateway.",
		"emits": {
			"@kentcdodds/discord.message.created": {
				"description": "A Discord message was created.",
				"payloadSchema": {
					"type": "object",
					"properties": {
						"messageId": { "type": "string", "minLength": 1 },
						"channelId": { "type": "string" }
					},
					"required": ["messageId", "channelId"],
					"additionalProperties": false
				}
			}
		}
	}
}
```

`payloadSchema` is optional. When present it must be a JSON Schema subset with
root `"type": "object"`; supported keywords are `type`, `description`,
`properties`, `required`, `additionalProperties` (boolean), `items`, `enum`,
`const`, `minLength`, `maxLength`, `minimum`, `maximum`, `minItems`, and
`maxItems`. Unsupported keywords fail package checks at publish time so authors
never rely on silently ignored constraints. Declared schemas appear in package
search/detail projections so subscribers can discover payload shapes.

### Emitting

Emit from any package runtime context (exports, subscription handlers,
package-owned jobs, apps, retrievers) with the `events` helper:

```ts
import { events } from 'kody:runtime'

await events.dispatch({
	topic: '@kentcdodds/discord.message.created',
	idempotencyKey: `discord:message-create:${message.id}`,
	payload: { messageId: message.id, channelId: message.channelId },
})
```

Rules:

- The topic must be declared in the emitting package's `kody.emits`.
- `idempotencyKey` is required; payloads must be JSON objects and are validated
  against `payloadSchema` when declared.
- Payloads are capped at 64 KiB (canonical JSON). Store large data with
  `packageStorage()` and emit a reference instead.
- `events.dispatch` is unavailable in ad hoc `execute` runs — topics belong to
  packages, so emit from package code (or statically import a package export
  that dispatches).

### Delivery semantics

Dispatch is asynchronous and durable: `events.dispatch` validates the event,
enqueues it on the `kody-package-events-dispatch` Queue (with DLQ), and returns
`{ topic, source, idempotencyKey, status: "enqueued" }` immediately. Emitters
never observe subscriber results or latency; check each subscriber's run records
for handler outcomes.

The Queue consumer resolves the emitting user's subscribed packages at delivery
time and invokes each `subscription:@scope/topic` handler with:

```ts
type PackageEventEnvelope = {
	event: string
	source: { type: 'package'; package_id: string; kody_id: string }
	idempotency_key: string
	payload: Record<string, unknown>
}
```

- Per-subscriber invocations are exactly-once keyed on
  `(source package, subscriber package, topic, idempotencyKey)`, so Queue
  redelivery replays stored results instead of re-running handlers.
- Infrastructure failures before handler code runs retry via the Queue (3
  attempts, then the `kody-package-events-dispatch-dlq` dead-letter queue).
  Terminal handler failures do not retry — a stored failed invocation replays
  rather than re-running — and stay visible in run records.
- Event-driven chains carry a nested invocation depth budget (max 8 hops), so
  emit cycles between packages terminate.
- In environments without the Queue binding (local dev, preview) — or when an
  enqueue fails — dispatch falls back to inline delivery with the same consumer
  code path and reports `status: "delivered_inline"` instead of `"enqueued"`.

### Filters on package-emitted topics

A subscription to a package-emitted topic may declare `filters`; every filter
key must be present in the event payload with an equal JSON value or the
subscriber is skipped:

```json
{
	"kody": {
		"subscriptions": {
			"@kentcdodds/discord.message.created": {
				"handler": "./src/on-general-chat-message.ts",
				"filters": { "channelId": "1470913684598423592" }
			}
		}
	}
}
```

Platform-owned topics (below) keep their existing behavior: their dispatchers
define whether and how `filters` apply.

## `email.message.received`

Accepted stored inbound email dispatches `email.message.received` after Kody
stores the message and attachment metadata. Quarantined mail uses
`email.message.quarantined` instead.

Handlers receive a metadata-first payload:

```ts
type EmailMessageReceivedEvent = {
	event: 'email.message.received'
	message: {
		id: string
		inbox_id: string | null
		from_address: string | null
		envelope_from: string | null
		to_addresses: Array<string>
		cc_addresses: Array<string>
		reply_to_addresses: Array<string>
		subject: string | null
		message_id_header: string | null
		in_reply_to_header: string | null
		references: Array<string>
		processing_status: 'stored' | 'sent' | 'failed'
		received_at: string | null
		created_at: string
	}
	attachments: Array<{
		id: string
		filename: string | null
		content_type: string | null
		content_id: string | null
		disposition: string | null
		size: number
		storage_kind: string
		storage_key: string | null
		created_at: string
	}>
}
```

Do not expect parsed bodies or attachment bytes in the event. Fetch full message
bodies, parsed headers beyond the event metadata, or attachment bytes only when
the handler needs them with `emailMessageGet`, `emailAttachmentGet`, or the
package runtime `email` helper.

## `email.message.quarantined`

Quarantined stored inbound email dispatches `email.message.quarantined` instead
of `email.message.received`. The payload matches `email.message.received` with
`event: 'email.message.quarantined'`. Reclassifying a message later does not
retroactively dispatch either topic.

## `email.message.delivery.updated`

Outbound Email Sending lifecycle changes dispatch
`email.message.delivery.updated`. The payload contains metadata for the owned
Kody message plus the provider event id, delivery status, terminal flag,
recipient, SMTP delivery fields, optional bounce/failure/rejection/complaint
details, and provider event timestamp.

Use this topic for delivery notifications and bounce or complaint workflows. Do
not resend on `deferred`: Cloudflare still has provider retries pending.
Provider event ids are stored idempotently, so duplicate Queue delivery does not
dispatch duplicate package invocations. Out-of-order events remain available in
delivery history but do not dispatch after a newer status.

## `run.error.recorded`

When a user-scoped Activity / run record finishes with `status: 'error'`, Kody
dispatches `run.error.recorded` to packages saved by that same user that declare
the topic. Delivery is best-effort after a successful run-record Durable Object
write — there is no Queue / DLQ for this topic. Failures during subscriber
discovery or package-invocation infrastructure are logged and do not fail the
observed run.

Handlers receive a metadata-first payload:

```ts
type RunErrorRecordedEvent = {
	event: 'run.error.recorded'
	run: {
		id: string
		surface: string
		name: string | null
		package_id: string | null
		kody_id: string | null
		source_id: string | null
		published_commit: string | null
		storage_id: string | null
		job_id: string | null
		workflow_id: string | null
		invocation_id: string | null
		session_id: string | null
		parent_run_id: string | null
		started_at: string
		finished_at: string | null
		duration_ms: number | null
		error_name: string | null
		error_message: string | null
	}
	activity_url: string
}
```

`activity_url` is built from the trusted deployment origin and links to
`/account/activity/<runId>`. The event deliberately omits log lines and the full
run `metadata` blob — fetch detail with `runGet` when needed. Error name and
message use the same truncation budget as the stored run record.

Recursion guard: runs whose surface is `subscription` never emit this event.
Subscription-handler failures themselves create run records; emitting again
would recurse. Successful runs and `execute` successes (which are not persisted)
never emit. Failed `execute` calls do persist and do emit.

Use this topic for notifier packages that email, write to Sheets, spawn an
agent, or otherwise react when something in the user's account fails.

## `integration.auth.failed`

When host-side OAuth token refresh fails with reconnectable caller state —
missing refresh token, provider HTTP 4xx / `invalid_grant`, missing secrets,
host-approval gaps, or invalid connection config — Kody dispatches
`integration.auth.failed` to packages saved by that same user that declare the
topic. Every classified attempt emits. The platform does not coalesce repeats;
notifier packages decide how often to ping, typically by pairing this topic with
`integration.auth.succeeded` and storing last-known health in package storage.
Provider HTTP 5xx and missing connections do not emit.

Delivery is best-effort after the refresh caller error is classified — there is
no Queue / DLQ for this topic. Failures during subscriber discovery or
package-invocation infrastructure are logged and do not change the refresh error
the caller sees.

Handlers receive a metadata-first payload:

```ts
type IntegrationAuthFailedEvent = {
	event: 'integration.auth.failed'
	event_id: string
	integration: {
		name: string
		lane: 'user' | 'platform'
		account_label: string | null
		description: string | null
		provider: string | null
		platform_app_slug: string | null
		scopes: Array<string>
		connected_at: string | null
		token_refreshed_at: string | null
	}
	reason:
		| 'missing_refresh_token'
		| 'provider_rejected'
		| 'missing_secret'
		| 'host_not_approved'
		| 'invalid_config'
	provider: {
		error: string | null
		error_description: string | null
		http_status: number | null
	}
	reconnect_url: string
	account_url: string
	occurred_at: string
}
```

`reconnect_url` is built from the trusted deployment origin and links to
`/connect/oauth?provider=<name>`. When `account_label` looks like an email it
also adds `loginHint` so Google/OIDC can preselect that account. `account_url`
is the connection detail page (`/account/integrations/<name>`). The event
deliberately omits token values, secret values, client secrets, and secret
names. A short-lived access token that refreshes cleanly never emits. Successful
Google refreshes persist `userinfo.email` onto an empty `account_label` so later
reconnect pings can name the account.

Use this topic for notifier packages that post to Discord, email, or otherwise
ask the owner to reconnect a dead grant.

## `integration.auth.succeeded`

When host-side OAuth token refresh persists a new access token, or
`/connect/oauth` finishes saving tokens for a connection, Kody dispatches
`integration.auth.succeeded` to packages saved by that same user that declare
the topic. Every successful refresh and every successful connect persist emits.
Sequential attempts are not coalesced; concurrent in-flight refreshes of the
same connection share one attempt. The platform does not track working ↔ failed
itself; notifier packages store that edge in package storage so a later failure
can notify only on the working → failed transition.

Delivery is best-effort after the tokens are written. Failures during subscriber
discovery or package-invocation infrastructure are logged and do not change the
refresh result or the connect response.

Handlers receive a metadata-first payload:

```ts
type IntegrationAuthSucceededEvent = {
	event: 'integration.auth.succeeded'
	event_id: string
	integration: {
		name: string
		lane: 'user' | 'platform'
		account_label: string | null
		description: string | null
		provider: string | null
		platform_app_slug: string | null
		scopes: Array<string>
		connected_at: string | null
		token_refreshed_at: string | null
	}
	source: 'refresh' | 'oauth_connect'
	account_url: string
	occurred_at: string
}
```

`source` is `refresh` for `refreshIntegrationTokens` and `oauth_connect` for the
`/connect/oauth` persist path. `account_url` is built from the trusted
deployment origin and links to `/account/integrations/<name>`. The event
deliberately omits token values, secret values, client secrets, and secret
names.

Use this topic with `integration.auth.failed` to flip stored health back to
working after a reconnect, or to send an all-clear.

## `mcp.server.disconnected` / `mcp.server.reconnected`

When a saved, enabled outbound MCP server leaves `ready` and stays unavailable
after the hub's lightweight reconnect (two `connectToServer` + discover
attempts, no OAuth restart), Kody dispatches `mcp.server.disconnected` to
packages saved by that same user that declare the topic. When that down episode
later observes `ready` again, Kody dispatches `mcp.server.reconnected` with the
same `server.episode_id`.

Never-ready servers (still `authenticating` after add, with no stored tokens and
no token-recovery `last_error`), disabled servers, and in-flight `connecting` /
`connected` / `discovering` states do not emit. A durable token-recovery park
(`authenticating` plus “no refresh token” / phase token exchange) is a working →
failed flip: the hub infers previously-ready when the episode bit is missing,
stamps `last_error`, and queues `mcp.server.disconnected` without the
lightweight retry. Waiting and search peeks, account-page snapshots, and hub
mutations dispatch that pending event to same-user packages that declare the
topic (for example a Discord notifier). Those requests pass `waitUntil` so the
package invoke can finish after the response; the hub acks only the dispatched
event ids after fan-out returns complete. Incomplete discovery, retryable invoke
failures, and a failed enabled-server lookup leave the event pending instead of
acking it. `mcpServerReconnect` tries stored refresh first, then mints a new
authorization URL; listener packages should not call it on every event.

Delivery is best-effort after the hub observes the transition — there is no
Queue / DLQ for these topics. Failures during subscriber discovery or
package-invocation infrastructure are logged and do not fail the MCP tool call
or snapshot that noticed the change.

Handlers receive a metadata-first payload:

```ts
type McpServerConnectionEvent = {
	event: 'mcp.server.disconnected' | 'mcp.server.reconnected'
	event_id: string
	server: {
		id: string
		name: string
		state: string
		previous_state: string
		episode_id: string
	}
	observed_at: string
	account_url: string
}
```

`account_url` is built from the trusted deployment origin and links to
`/account/mcp-servers/<id>`. The event omits server URLs, OAuth tokens, bearer
headers, auth URLs, and discovered tool lists. Fetch live status with
`mcpServerList` when needed. Idempotency keys include the topic, episode id, and
subscriber package id, so one disconnected and one reconnected invoke per
episode.

Use these topics for notifier packages that post to Discord or otherwise tell
the owner an MCP server (for example `home`) dropped or came back. Do not scrape
run-error strings for connection health.

## `repo.pushed`

When Cloudflare Artifacts reports commits pushed to a Kody-managed Artifacts
repo (plain repo, package, or job source), Kody dispatches `repo.pushed` to
packages saved by that same user that declare the topic. Delivery is durable via
the `kody-artifacts-repo-events` Queue (with DLQ). Session fork repos, session
workspace branch pushes (`sessions/<id>`), and publish git-notes
(`refs/notes/commits`) never emit. Opening a repo session git-pushes the session
ref, and delivering it as `repo.pushed` would let a handler that opens another
session loop. Publish attaches a metadata note after the source-branch push;
that second git update is not a content push. Events for other
`ARTIFACTS_NAMESPACE` values are ignored. Handlers that only care about default
branch content should still check `push.ref`.

Handlers receive a metadata-first payload:

```ts
type RepoPushedEvent = {
	event: 'repo.pushed'
	repo: {
		source_id: string
		repo_id: string
		entity_kind: 'repo' | 'package' | 'job'
		entity_id: string
		name: string | null
		kody_id: string | null
	}
	push: {
		ref: string
		before: string
		after: string
		total_commits_count: number
		commits_truncated: boolean
		commits: Array<{
			id: string
			message: string
			message_truncated: boolean
			timestamp: string
			author: { name: string; email: string }
			committer: { name: string; email: string }
			parents: Array<string>
		}>
	}
	artifacts: {
		namespace: string
		event_timestamp: string
		event_subscription_id: string
	}
}
```

`repo_id` is the Artifacts repo name (also stored on `entity_sources.repo_id`).
`name` is the user-facing plain-repo name or package npm name when known; The
package name leaf is set for packages. For `entity_kind: 'package' | 'job'`, a
push updates live HEAD but does not mean the package/job published commit
advanced — use publish / external-push / reconcile for activation.

Idempotency keys include the after commit, ref, and subscriber package id, so
Queue redelivery is safe.

## `repo.created` / `repo.deleted`

Account-level Artifacts create/delete events map to `repo.created` and
`repo.deleted` with the same `repo` entity block plus Artifacts metadata
(`default_branch`, `description`, Cloudflare `cloudflare_repo_id`). Same-user
fan-out and Queue delivery match `repo.pushed`. Unmatched deletes (D1 row
already gone) are acknowledged without retry.

## `package.codemod.applied`

After a successful package codemod **apply**, Kody dispatches
`package.codemod.applied` to packages saved by the **owning user** of the
migrated package that declare the topic. Delivery follows the same best-effort
host dispatch path as `run.error.recorded` — there is no Queue / DLQ for this
topic. Failures during subscriber discovery or package-invocation infrastructure
are logged and do not fail the codemod apply.

Handlers receive a metadata-first payload:

```ts
type PackageCodemodSubscriptionEnvelope = {
	event: 'package.codemod.applied'
	codemod: {
		id: string
		description: string
	}
	package: {
		package_id: string
		kody_id: string
	}
	run: {
		run_id: string
		item_id: string
	}
	changed_paths: Array<string>
	before_commit: string | null
	after_commit: string | null
}
```

`changed_paths` lists published-tree paths the codemod transform modified.
`before_commit` and `after_commit` are the package's published commit before and
after apply. The event deliberately omits file contents — fetch the current
published source with repo or package capabilities when a handler needs diffs or
full files. Community listing snapshots are unchanged by apply; only the owning
saved package advances. `run.item_id` is the apply ledger item id.

Use this topic for notifier packages that record migrations, ping owners, or
trigger follow-up automation when platform codemods rewrite user package source.

## `package.codemod.reverted`

After a successful package codemod **revert**, Kody dispatches
`package.codemod.reverted` to packages saved by the **owning user** of the
restored package that declare the topic. Delivery semantics match
`package.codemod.applied` and `run.error.recorded`.

Handlers receive the same envelope shape with
`event: 'package.codemod.reverted'`:

```ts
type PackageCodemodSubscriptionEnvelope = {
	event: 'package.codemod.reverted'
	codemod: {
		id: string
		description: string
	}
	package: {
		package_id: string
		kody_id: string
	}
	run: {
		run_id: string
		item_id: string
	}
	changed_paths: Array<string>
	before_commit: string | null
	after_commit: string | null
}
```

For revert, `before_commit` is the post-codemod published commit (the source
apply item's `afterCommit`) and `after_commit` is the restored pre-codemod
commit. `changed_paths` is copied from the source apply item (paths the codemod
originally changed), not recomputed at revert time. `run.item_id` is the new
revert-run ledger item id. Revert snapshots expire from KV after 90 days, so
revert and this event are unavailable once the snapshot is gone.

Use this topic when automation must react to an operator or user undoing a prior
codemod apply.
