Execute
How execute runs your code: module shape, imports, params, what comes back, limits, idempotency, packages, storage and authenticated fetch.
execute runs one ES module in Minion's sandbox. The module must default export the function Minion calls. You can write it in JavaScript or TypeScript.
Shape of the code
Export a default async function. Name its argument params so it mirrors execute({ code, params }):
export default async function main(params) {
// Use params directly, or pass them to helpers in the same module.
} If the module has no default export, the call fails with "The module must export default async function main(params)".
execute takes three inputs:
code: the module source, up to 256 KB.params: an optional JSON object. Minion passes it to your default export as the first argument.idempotencyKey: an optional string, up to 200 characters. See Recovering from timeouts .
Top-level await works.
What the module can use
minion is a global inside the sandbox, so you can call capabilities without importing anything. Call them as await minion.<domain>.<fn>(input), for example minion.secrets.list() or minion.runs.get({ id }). Use search to find them.
You can also import from minion:runtime:
minion: the same capability object as the global.fetch: the same gateway fetch as the global.createAuthenticatedFetch(name): a fetch bound to an OAuth integration. See Network and OAuth .
Saved package code gets two more exports from minion:runtime: packageStorage() and sql(). Ad hoc execute code doesn't have them. See Storage .
To reuse a saved package, import one of its exports by name:
import digest from 'minion:@your-org/daily-digest'
import { summarize } from 'minion:@your-org/daily-digest/summarize' The path after the package name is the export key from the package's package.json exports (./summarize above). With no path you get the . export. Imports resolve when the code is bundled, to the package's current runnable version. You can only import packages from your own organization (@<org slug>/...). Importing another organization's package fails.
Nothing else can be imported:
- Relative paths, absolute paths and URLs are rejected. Send one module.
- Node built-ins (
node:fs,path,cryptoand the rest) are rejected. - npm packages must be on the sandbox allowlist. Today that list is
zodandhono(including subpaths such ashono/html).
The sandbox has Workers-style globals, not Node: fetch, Headers, console, crypto (random values, randomUUID, SHA digests and HMAC), timers, AbortController, TextEncoder/TextDecoder, atob/btoa, URL, URLSearchParams, structuredClone and performance.now(). process, Buffer, require and the Request/Response constructors aren't there.
Pass values through params
Put values that change in params, not in string literals inside code. Minion caches the bundle by source, so the same module with different params skips rebundling. It also keeps the module reusable.
Instead of this:
export default async function main() {
return await minion.account.notifySelf({
subject: 'Hello from Minion',
text: 'Notify-self mail from an execute module.',
})
} Write this:
export default async function main(params) {
return await minion.account.notifySelf(params)
}
// execute({ code, params: { subject: 'Hello from Minion', text: 'Notify-self mail from an execute module.' } }) What execute returns
A successful call returns:
runId: the run record for this call.result: whatever your default export returned, as JSON.logs: an array of{ level, text }fromconsolecalls.replayed:truewhen the result came from an earlier run with the sameidempotencyKey.
A failed call returns runId, error ({ kind, message, stack? }), logs and replayed, and is marked as an error. Common kind values:
bundle: an import couldn't be resolved, or the code doesn't compile.error: your code threw. The message and the first lines of the stack come back.timeout: the run hit the wall-clock or CPU limit.memory: the run hit the memory limit.response_too_large: the result was bigger than the response limit.limit: a plan limit stopped the call before it ran.in_progress: a run with thisidempotencyKeyis still running.
When Minion finds memories related to your code or params, the response also has a memories field. See Memory .
Secret values and integration tokens are redacted from results, logs and errors before they come back.
Limits
Every plan gets the same sandbox limits per call:
- 90 seconds of wall-clock time.
- 20 seconds of CPU time.
- 128 MB of memory.
- 100 KB of result JSON. Return only the fields you need.
- 64 KB of logs. After that you get a
[logs truncated]line. - 60 seconds per outbound
fetch, 1 MB per request body, 5 MB per response. - 1,000 host calls (capabilities plus fetches) per run, 8 at a time.
- 1 MB per capability result.
Daily and weekly quotas also apply: execute calls, outbound fetches and capability calls each count against your plan. See /app/usage.
Chaining
Do the whole job in one execute call when the plan is clear: call several capabilities or package exports, branch on the results, and return the final result. Split it into several calls only when you need new input from a person, a confirmation, or a result that changes the plan.
Work that won't fit in 90 seconds belongs in a saved package with a job, not a long execute call. See Jobs, workflows, and webhooks .
Recovering from timeouts
Every execute call is recorded as a run, whether it succeeds or fails. The run is written as running when it starts and updated when it finishes. You can see it in Activity and read it with minion.runs.get({ id: runId }).
If your MCP client gives up before the call returns, don't blindly retry something with side effects. Pass an idempotencyKey on calls that must not run twice:
- Retrying with the same key after the first run finished returns the stored result with
replayed: trueand the samerunId. Nothing runs again. - Retrying while the first run is still going returns an
in_progresserror with therunId. Pollminion.runs.get({ id: runId })untilstatusisn'trunning. - Keys are scoped to your organization and don't expire. Use a new key for a new piece of work.
Replays don't count against your execute quota.
Saved packages
When a snippet works and you'll need it again, save it as a package. Saved packages, their jobs and ad hoc execute code all run in the same sandbox.
- A package is a set of source files rooted at
package.json, named@<org slug>/<name>, with aREADME.md. - Exports come from the standard
exportsfield inpackage.json. - Jobs are declared in
package.jsonunderminion.jobs, as{ name, cron, export?, timezone? }. A job runs the default export of the entry it names. minion.packages.save({ files })saves a new version.minion.packages.publish({ name, version })runs checks and makes it the version that runs. Owners and admins publish.minion.jobs.runNow({ id })runs a job right away.minion.jobs.update({ id, changes: { enabled } })turns it on or off. To change a schedule, edit the package and publish.
See Packages and Package authoring .
Storage
Ad hoc execute code has no storage of its own. Keep durable state in a saved package with packageStorage():
import { packageStorage, sql } from 'minion:runtime'
export default async function main() {
const store = packageStorage()
const last = await store.get('lastIssueId')
await store.set('lastIssueId', 4812)
const { rows, rowCount } = await sql('select id, title from issues where id > $1', [last ?? 0])
return { rows, rowCount }
} packageStorage() gives you get, set, list, delete, clear, export and sql. sql() runs one SQLite statement in the package's own database, with $1, $2 params, and returns { rows, rowCount }, up to 1,000 rows.
Storage belongs to the package whose code is running. When you import a package from execute, that package's code still reads and writes its own storage. Your execute code can't name another package's storage directly. Owners and admins can read any package's storage from execute for debugging with minion.storage.get, minion.storage.list and minion.storage.export. See Package storage and SQL .
Network and OAuth
fetch goes through Minion's gateway. Put secrets in a request as {{secret:name}} placeholders in the URL, a header or the body. Minion fills them in only for hosts approved on that secret, and redacts them from the response. Private and internal addresses are blocked. Redirects come back to you instead of being followed.
For OAuth integrations, use createAuthenticatedFetch. It returns a fetch function right away, so there's nothing to await before you call it:
import { createAuthenticatedFetch } from 'minion:runtime'
export default async function main() {
const github = createAuthenticatedFetch('github')
const res = await github('https://api.github.com/user')
return await res.json()
} Minion attaches the access token on the host and refreshes it on expiry or a 401. The token never enters your code. You can do the same with minion.integrations.fetch({ name, url, init }), or put {{integration:name}} in a request header yourself.
See Secrets and host approval and OAuth . Never put a resolvable {{secret:...}} placeholder into text shown to people or sent to third parties.
Named state
Durable facts and preferences belong in memories. Package runtime state belongs in packageStorage() or sql(). Longer working documents belong in notes. API keys and tokens belong in secrets. OAuth tokens live on the integration.
MCP server instructions
Each member can add their own notes to Minion's MCP server instructions, per organization, on /app/connections. They're appended after Minion's built-in instructions, up to 4,000 characters. Some MCP clients only keep the first 2,048 characters of server instructions, and the page warns you when the total passes that. Changes apply to new MCP sessions, so reconnect your client. Prefer memories for durable facts.