Skip to docs content
Browse documentation
On this page

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 in exports. Defaults to ".".
  • description (optional): up to 200 characters. Shown by minion.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 execute code can't, because it has no package to emit for.
  • idempotencyKey is 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 as duplicate.
  • The payload must be a JSON object of at most 64 KB. For anything bigger, store it with packageStorage() or sql() and emit a reference.
  • dispatch returns right away with one entry per subscriber: started, duplicate or start_failed. skipped.filters counts subscribers whose filters didn't match. Handlers run in their own runs. The emitter never waits for them.
  • A start_failed delivery 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 }
  • package is the package name or id. The package must subscribe to topic in its runnable version.
  • params becomes 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 in minion.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, handler runId, error, depth and synthetic. Open the run with minion.runs.get({ id: runId }).

If a handler didn't run:

  1. Check subscriptionsList for the topic. No row means the runnable version doesn't declare it. Publish again.
  2. Check the filters against the real payload. Values must match exactly, including type.
  3. Check deliveriesList. rejected usually means the daily event run quota is used up.
  4. For a package topic, check dispatch's return value for duplicate (same idempotency key) or skipped.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.