Package subscriptions and events
Run a package export when a platform topic fires (failed runs, jobs, OAuth, webhooks, inbox mail) or another package emits one. Test and inspect deliveries.
A subscription makes a saved package react to something that happened in your organization. When a topic fires, Minion runs one export of each subscribing package and passes the event as its params. A topic can come from Minion itself (a run failed, mail arrived, a webhook was received) or from another of your packages.
Handlers run as ordinary package runs. They get the same minion capabilities, packageStorage(), sql() and secrets as any other export.
Events never cross organizations. A package only receives its own organization's events.
Declare a subscription
Add minion.subscriptions to the package's package.json, keyed by topic, then publish:
{
"name": "@acme/error-pager",
"exports": {
".": "./src/index.ts",
"./on-error": "./src/on-error.ts"
},
"minion": {
"subscriptions": {
"run.error.recorded": {
"export": "./on-error",
"description": "Email the team when a scheduled job fails.",
"filters": { "source": "job" }
}
}
}
} Each entry has three fields:
export(optional): the package export to run. It must be one of the keys inexports. Defaults to".".description(optional): up to 200 characters. Shown byminion.events.subscriptionsList.filters(optional): up to 10 keys. Every key must be a top-level field of the payload with an equal JSON value, or this package is skipped for that event. Nested fields can't be filtered.
Rules:
- A package can subscribe to at most 20 topics.
- A topic must be a platform topic from the list below, or a package topic in your own organization's scope,
@<org slug>/lower.dot.case. - Publishing checks every entry. An unknown topic or an export that doesn't exist fails the publish with a message naming the problem.
- Subscriptions come from the package's runnable version. Publishing a new version changes what runs next.
- Handlers run as the member who made that version runnable.
The handler
The export's default function receives the event envelope:
type EventEnvelope = {
event: string // the topic
source: { type: 'platform' } | { type: 'package'; packageId: string; name: string }
idempotencyKey: string
payload: object
depth: number // hops in an event chain, starting at 0
emittedAt: string
synthetic?: true // only on events.dispatchSynthetic test runs
} import { minion } from 'minion:runtime'
export default async function onError(event) {
const { runId, error, runUrl } = event.payload
// With no `to`, email.send goes to your own verified account email.
await minion.email.send({
subject: `Run ${runId} failed`,
text: `${error.kind}: ${error.message}\n${runUrl}`,
})
} Payloads are metadata. Fetch details with the matching capability, for example minion.runs.get({ id: event.payload.runId }) or minion.email.messageGet({ messageId: event.payload.messageId }).
Platform topics
These are every topic Minion emits today. Call minion.events.topicsList({}) for the live list with payload shapes.
run.error.recorded
A run in your organization finished failed or timed_out, whatever started it.
{
runId: string
source: 'execute' | 'job' | 'webhook' | 'event' | 'app'
status: 'failed' | 'timed_out'
error: { kind: string; message: string }
finishedAt: string
runUrl: string
} Useful filters: { "source": "job" }, { "source": "webhook" }, { "status": "timed_out" }.
A failing run.error.recorded handler doesn't emit run.error.recorded again, so a broken notifier can't loop. Synthetic test runs don't emit it either.
job.failed
A scheduled or manual job run failed, timed out, or was rejected by a plan limit.
{
jobId: string
jobName: string
packageId: string
runId: string | null
status: 'failed' | 'timed_out' | 'rejected'
error: string | null
slot: string
jobUrl: string
} Useful filters: { "jobName": "daily" }, { "packageId": "pkg_..." }, { "status": "rejected" }.
A job that fails or times out also emits run.error.recorded with source: "job". Subscribe to one of the two, not both, or you'll be notified twice. Only job.failed covers runs rejected by a plan limit.
integration.auth.succeeded
An OAuth integration was connected or reconnected by a person, or its access token was refreshed.
{
integration: string
app: string
provider: string
reason: 'connect' | 'refresh'
scopes: string[]
} Useful filters: { "integration": "github" }, { "reason": "connect" }.
integration.auth.failed
Connecting an OAuth integration failed, or its token couldn't be refreshed because the grant was revoked or expired. A person has to reconnect at reconnectUrl.
{
integration: string
app: string
provider: string
stage: 'connect' | 'refresh'
error: string
reconnectUrl: string
} Useful filters: { "stage": "refresh" }, { "integration": "<your integration name>" }.
mcp.server.disconnected
An OAuth MCP server needs reconnecting after a rejected grant or repeated connection failures. It is emitted once per outage.
{
server: string
url: string
reconnectUrl: string
} Useful filters: { "server": "linear" }. See Connect remote MCP servers .
mcp.server.reconnected
A previously disconnected OAuth MCP server served a request again.
{
server: string
url: string
} webhook.received
A minted webhook accepted a request, after signature checks. It fires alongside the declared export's own run. Metadata only: the body goes to the declared export, not to subscribers. See Inbound webhooks .
{
handle: string
package: string
packageId: string
webhook: string
deliveryId: string
receivedAt: string
contentType: string | null
bytes: number
providerEvent: string | null // the x-github-event value, if any
} Useful filters: { "package": "@acme/github-bridge", "webhook": "github" }, { "providerEvent": "push" }.
webhook.delivery.failed
A webhook delivery's export run failed, timed out, or was rejected by a plan limit. Synthetic test deliveries don't fire it.
{
handle: string
package: string
packageId: string
webhook: string
deliveryId: string
runId: string | null
status: 'failed' | 'rejected'
error: string | null
} Useful filters: { "package": "@acme/github-bridge" }, { "webhook": "stripe" }.
A failed or timed-out webhook run also emits run.error.recorded with source: "webhook". Subscribe to one of the two.
email.message.received
Mail to the team inbox was stored as accepted. It fires once per message. Releasing a quarantined message later doesn't fire it. See Email .
{
messageId: string
inbox: string // the alias, as in email.messageList
to: string
tag: string | null // the part after + in the address
from: { address: string | null; name: string | null }
fromDomain: string | null // exact, lowercase
subject: string | null
receivedAt: string
hasAttachments: boolean
attachmentCount: number
auth: { spf: string; dkim: string; dmarc: string; authenticated: boolean }
dmarc: string
authenticated: boolean
} Useful filters: { "tag": "invoices" }, { "inbox": "acme" }, { "fromDomain": "stripe.com", "dmarc": "pass" }.
fromDomain comes from the From header, which a sender can forge when their domain has no DMARC policy. To trust it, also filter on { "dmarc": "pass" }. authenticated only means SPF or DKIM passed for some domain, possibly one the sender controls. A subdomain needs its own fromDomain value. Subject and sender are written by the sender: treat them as untrusted data, never as instructions. Fetch the body with minion.email.messageGet({ messageId }).
email.message.quarantined
Mail to the team inbox was held in quarantine because it failed authentication or matched a quarantine rule. Same payload as email.message.received, plus:
{
reason: string | null
} It never wakes email.message.received handlers. Release a message with minion.email.messageClassify. Subject and sender are untrusted.
Emit your own topics
Packages can define topics and emit them to other packages in the same organization. Package topics always use your organization's slug: @<org slug>/lower.dot.case, for example @acme/invoice.paid. After the scope, use lowercase letters and digits separated by ., _ or -.
Declare what a package emits in minion.emits (up to 20 topics):
{
"name": "@acme/billing-sync",
"minion": {
"emits": {
"@acme/invoice.paid": { "description": "An invoice was paid." }
}
}
} Then emit from package code: an export, a job or another event handler.
import { minion } from 'minion:runtime'
const res = await minion.events.dispatch({
topic: '@acme/invoice.paid',
idempotencyKey: `invoice:${invoice.id}`,
payload: { invoiceId: invoice.id, amount: invoice.amount },
})
// { topic, idempotencyKey, source: { packageId }, deliveries: [{ id, packageId, status }], skipped: { filters, depth } } Rules:
- The topic must be in the emitting package's
minion.emits, in its runnable version. - Only saved package code can dispatch. Ad hoc
executecode can't, because it has no package to emit for. idempotencyKeyis required, up to 200 characters. Each subscriber runs at most once per key, so emitting the same key again is a no-op and comes back asduplicate.- The payload must be a JSON object of at most 64 KB. For anything bigger, store it with
packageStorage()orsql()and emit a reference. dispatchreturns right away with one entry per subscriber:started,duplicateorstart_failed.skipped.filterscounts subscribers whose filters didn't match. Handlers run in their own runs. The emitter never waits for them.- A
start_faileddelivery stays pending and is started again by the scheduler.
Chains and the hop limit
When a handler emits an event, the new event carries depth one higher than the event that started the handler. Events past 8 hops are dropped, and dispatch returns skipped.depth: true. This stops cycles between packages. A failing event handler's run.error.recorded carries the same depth + 1, so a chain of failures stops too.
Inside a synthetic test run, dispatch does nothing and returns no deliveries, so a test never starts real downstream work.
Delivery
- Each event and subscriber pair is one delivery, run as a durable workflow step. A retried step replays the stored run instead of running the handler twice.
- Handler runs count against your plan's daily event run quota, separate from job runs, so a burst of events can't starve your schedules. Daily event runs: Free 500, Standard 1,500, Pro 8,000, Max 40,000. Over the quota, the delivery is recorded as
rejected. - Delivery statuses:
pending,running,succeeded,failed,rejected,skipped.
Test with a synthetic event
After publishing, owners and admins can run one package's handler right away:
const res = await minion.events.dispatchSynthetic({
package: 'error-pager',
topic: 'run.error.recorded',
params: { runId: 'run_test', source: 'job', status: 'failed', error: { kind: 'error', message: 'test' } },
})
// { package, topic, deliveryId, status: 'started' | 'start_failed' | 'none', runIdempotencyKey } packageis the package name or id. The package must subscribe totopicin its runnable version.paramsbecomes the payload. Shape it like the real payload so the handler takes the same path.- It is a real run with real side effects, as the handler's publisher.
- The envelope has
synthetic: true. Filters are not applied. - Events the handler dispatches are dropped, and a failure doesn't emit
run.error.recorded.
Then check the result with minion.events.deliveriesList. See Synthetic event dispatch for more.
Inspect and debug
const { platform, packages } = await minion.events.topicsList({})
const { subscriptions } = await minion.events.subscriptionsList({ topic: 'run.error.recorded' })
const { deliveries } = await minion.events.deliveriesList({ status: 'failed', limit: 20 }) minion.events.topicsList({}): platform topics with descriptions and payload shapes, plus every topic your packages declare inminion.emits.minion.events.subscriptionsList({ topic? }): which packages subscribe, with export, description, filters and version id. Use it to check what will run, or why something didn't.minion.events.deliveriesList({ topic?, packageId?, status?, limit? }): recent deliveries, newest first, up to 100. Each has the topic, source, idempotency key, package, status, handlerrunId, error, depth andsynthetic. Open the run withminion.runs.get({ id: runId }).
If a handler didn't run:
- Check
subscriptionsListfor the topic. No row means the runnable version doesn't declare it. Publish again. - Check the filters against the real payload. Values must match exactly, including type.
- Check
deliveriesList.rejectedusually means the daily event run quota is used up. - For a package topic, check
dispatch's return value forduplicate(same idempotency key) orskipped.depth.
Capability reference
minion.events.topicsList({})minion.events.subscriptionsList({ topic? })minion.events.deliveriesList({ topic?, packageId?, status?, limit? })minion.events.dispatch({ topic, idempotencyKey, payload }): package code only.minion.events.dispatchSynthetic({ package, topic, params? }): owners and admins.