Jobs, workflows, and webhooks
How work runs when nobody is in the chat. Package-owned jobs for recurring schedules, workflows for deferred or long one-shot runs, inbound webhooks for provider events, subscriptions for events inside Minion, and the inbox — which to pick, how each is declared, and how to test one safely before enabling it. Load this when someone asks how to run something later, on a schedule, or when a provider knocks.
Everything your agent builds in Minion can run without the agent. That is the difference between an automation and a chat transcript. Minion has four ways to start work when nobody is typing, and every one of them runs a package you own. No model in the loop, no tokens spent.
- The same thing on a schedule: a job
- One thing later, or something longer than a request: a workflow
- Something when Sentry, GitHub, Stripe, or a CLI POSTs: an inbound webhook
- Something when Minion itself emits an event: a subscription
Prefer the event that actually describes the moment (a webhook or a subscription) and reach for a schedule when no event exists. Cron is fine; it is just not the hero.
Jobs: recurring schedules that travel with the package
A job is declared in the package manifest, so the schedule ships with the behavior it runs:
{
"minion": {
"jobs": [
{
"name": "daily-digest",
"export": "./daily-digest",
"cron": "0 8 * * *",
"timezone": "America/Denver"
}
]
}
} - A job runs the default export of the export it names (
"."if you leaveexportout). The usual shape is a thin wrapper that calls a callable function and sends mail only when there is something to say. - Jobs start disabled. Run the job once with
minion.jobs.runNow({ id })to prove it, then enable it withminion.jobs.update({ id, changes: { enabled: true } }). A schedule you have never run once is a schedule you will debug at 8 a.m. - Shared durable state (cursors, the last seen id) belongs in
packageStorage(), or insql()when you want rows to query. jobs.updateonly enables or disables. Schedule, timezone, export and params come from the manifest, so change them in the package and publish. Changing a job's export or schedule disables it again.- Schedules can't run more often than your plan's minimum interval (15 minutes on Free, Standard and Pro).
Jobs do not hang off an integration or an MCP connection. If a "job" has no package, save the package first. Jobs and their runs show up on /app/jobs .
Workflows: deferred and durable one-shot work
A workflow is a durable run Minion executes later, outside the request that asked for it. Use one instead of plain execute for:
- later:
runAtin the future ("send the report Friday", "retry after the window"). To remind a person, use a task reminder - longer: batch sweeps, migrations, polling loops, or steps that would exceed execute's ~90 second budget; a workflow's export gets about 4.5 minutes
- once: an
idempotencyKeymakes a repeatedcreatereturn the existing run instead of starting a duplicate
import { minion } from 'minion:runtime'
export default async function main() {
return await minion.workflows.create({
package: 'daily-digest',
export: './rebuild-index',
runAt: new Date(Date.now() + 15 * 60_000).toISOString(),
idempotencyKey: 'rebuild-index:2026-09-08',
})
} A workflow always runs a saved package export. Follow runs with minion.workflows.list and cancel one that hasn't started with minion.workflows.cancel. Recurring work is a job, not a workflow that reschedules itself. See Workflows .
Inbound webhooks: the external HTTP knock
A webhook gives a package a credentialed ingress URL (the credential stays out of MCP tool output). A provider POSTs to it and Minion dispatches the validated request to the package export that owns it.
- Declare it under
package.json#minion.webhooks: aname, theexportit binds to, and (for vendor senders)verification, with the HMAC header and encoding. OmitsecretNamefor GitHub-style hooks so mint generates a package-owned signing secret. SetsecretNameonly for provider-issued secrets (Sentry, Stripe), and store that value as a secret. One webhook name binds one export; there is no wildcard. - Mint a handle with
minion.webhooks.mint(it returns ahandle, never the URL) and register it withminion.webhooks.apply. For a GitHub repo hook, put{{webhookSecret}}inconfig.secretwhen the webhook declares HMAC. Treat the URL as a credential; tool output never includes it. For a provider apply doesn't cover, an owner or admin copies the URL from /app/webhooks , where they can also reveal, rotate, disable or enable it. Rotate keeps the previous URL live for 24 hours, or until the first accepted delivery arrives on the new URL.
Declaring a webhook does not open ingress; minting does. Deliveries are rate-limited per webhook (default 60 per minute, at most 600), and body-only HMAC is replayable unless you opt into a timestamp window or delivery-id header. Full contract and payload shapes: Inbound webhooks .
Sentry noise, GitHub events, Stripe payments, a shortcut on your phone, a CLI on your laptop: anything that can POST JSON can start a package this way.
Subscriptions: events from inside Minion
When the trigger is something Minion already knows about (a message landing in your team inbox, a run error, a job failure, an integration losing auth, a webhook delivery), a package subscribes to that topic in package.json#minion.subscriptions and Minion invokes the export with the event envelope. After publishing, smoke-test the handler with minion.events.dispatchSynthetic. Reuse another package with a static minion:@ import. Topics, payloads, and package-emitted events: Subscriptions and events .
The inbox is a trigger too
Every team has an email address, {team-slug}@inbox.minion.codes. Mail that arrives there is stored and, when accepted, emits email.message.received, so "forward it to Minion" is a valid trigger for people and systems that can send email but cannot call an API. Quarantined mail emits email.message.quarantined instead. The same inbox is how a job tells you it finished: minion.email.send with no to mails your own verified address and nothing else. See Email primitives .
Choosing well
- A schedule is optional, not the point. If the person cannot name a time they want something to happen, leave the trigger off and let them ask.
- Test before you enable: run the job with
jobs.runNow, smoke-test a subscription withevents.dispatchSynthetic, or a webhook withwebhooks.dispatchSynthetic. - Keep the wrapper quiet. Notify only when there is news; an empty digest every morning trains people to ignore the real one.
- Failures and recent runs for every trigger live on /app/runs .
Where to go next
- Flake Hunter , Sentry Issues , Agent inbox , Purchase thanks : worked examples of each trigger.
- Package lifecycle : testing a scheduled wrapper before enabling its schedule.
- How Minion works : one loop from ad hoc question to a daily email that stays quiet until something ships.
- Workflows : the full
workflows.createreference.