Packages
Package subscriptions and events
Official Kody doc
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:
{
"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:
{
"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.
Reuse the scoped name from packageSubscriptionsList as leftover kody_id.
{
"kody_id": "@owner/email-automation",
"topic": "email.message.received",
"params": {}
}For stored inbound mail, replay with email_message_id instead of params:
{
"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: trueand, for stored-mail replay,replay_of. Real event dispatch strips caller-suppliedsyntheticandreplay_offrom handler envelopes. Run records agree with the handler payload. paramsoremail_message_id. Fixtureparamsmerge into the handler envelope before markers are added.email_message_idrebuilds 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
filtersdo not match the payload; synthetic dispatch always runs the named package. Put filter-matching fields insideparamswhen 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
subscriptionsurface. Handler failures do not emitrun.error.recorded(recursion guard).
Full call semantics and examples: Synthetic event dispatch.
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:
{
"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:
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. idempotencyKeyis required; payloads must be JSON objects and are validated againstpayloadSchemawhen declared.- Payloads are capped at 64 KiB (canonical JSON). Store large data with
packageStorage()and emit a reference instead. events.dispatchis unavailable in ad hocexecuteruns — 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:
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-dlqdead-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:
{
"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:
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:
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:
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:
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:
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:
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:
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':
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.