Package apps
Serve a Hono app from a package at https://<org>.runminion.run/<package>/: the minion.app block, members or public access, how members sign in, limits and the security model.
A package app gives a saved package its own web address. Declare minion.app in package.json, default-export a Hono app, and Minion serves it at https://<org slug>.runminion.run/<package>/. Use it for a small internal tool, a status page, a form, or a JSON endpoint that sits next to the package's data.
The manifest fields are in Package manifest . Package authoring basics (README, exports, JSDoc) stay in Package authoring , and durable data in package storage .
Declare the app
minion.app.export names one of the package's exports. access is "members" (the default: signed-in members of your organization) or "public" (anyone with the URL). description is optional, up to 200 characters.
{
"name": "@acme/guestbook",
"exports": { ".": "./src/index.ts", "./app": "./src/app.ts" },
"minion": {
"app": { "export": "./app", "access": "public", "description": "Sign the launch guestbook" }
}
} Access is set here in the manifest: change it and publish a new version to switch an app between members and public.
Write it with Hono
Default-export a Hono app. hono and its subpaths (hono/html, and so on) can be imported. Any object with a fetch(request) method, or a plain async (request) => Response function, works too.
import { Hono } from 'hono'
import { html } from 'hono/html'
import { packageStorage } from 'minion:runtime'
const app = new Hono()
const store = packageStorage()
app.get('/', async (c) => {
const base = c.req.header('x-minion-base-path')
const names: string[] = (await store.get('names')) ?? []
return c.html(html`<h1>Guestbook</h1>
<ul>${names.map((n) => html`<li>${n}</li>`)}</ul>
<form method="post" action="${base}/sign"><input name="name" /><button>Sign</button></form>`)
})
app.post('/sign', async (c) => {
const form = await c.req.parseBody()
const names: string[] = (await store.get('names')) ?? []
await store.set('names', [...names, String(form.name).slice(0, 80)])
return c.redirect(`${c.req.header('x-minion-base-path')}/`)
})
app.get('/api/names', async (c) => c.json((await store.get('names')) ?? []))
export default app Publish the package. The app is live at https://acme.runminion.run/guestbook/ and always runs the package's runnable version (locked, or else latest published). minion.packages.save returns the URL as app.url, and the package's page in Minion shows it with an Open app link.
Opening a members app
The apps domain is separate from app.minion.codes, so your Minion login doesn't reach it on its own. Members open a members app through Minion: https://app.minion.codes/a/<org slug>/<package>/ (the package page's Open app link) signs them in on the app's domain with a one-time token, then sends them to the app's address. After that the address works directly in that browser, until they sign out of Minion.
Opened directly without signing in, a members app shows an "Open this app from Minion" page with that link. A public app needs none of this: share its address.
Paths and links
Minion strips the mount before your app sees the request: /guestbook/sign arrives as /sign, and c.req.url is absolute. Root-relative links such as /sign in your HTML would leave the app, so build links, form actions and redirects from the x-minion-base-path request header (/guestbook).
How it runs
- One fresh sandbox per request. Nothing in memory survives between requests. Keep data in
packageStorage()or, in a members app,sql();minion.*andfetchwork as they do in any package. - Who it runs as. A members app runs as the member viewing it, capped at member level: an owner or admin opening it doesn't give it owner or admin powers. A public app runs as the package's creator, also capped at member level. If the creator leaves the organization, a public app stops (404) until someone else republishes it.
- What a public app can call. Public apps get
packageStorage(),minion.events.dispatchand outboundfetch. Every otherminion.*capability, includingsql()andminion.integrations.fetch, is refused with a "not available to public package apps" error. If a public app'sfetchuses a{{secret:...}}placeholder, that secret is spent on behalf of anonymous visitors, so only do that on purpose. - Usage. Each request counts as an app request on your plan (not as an execute call), and is recorded as a run with source
app(minion.runs.list({ source: 'app' })). Members apps and public apps have separate daily request quotas, so anonymous traffic can't use up the members quota. The run keeps the response status and size, not the body. - Not found looks the same everywhere. An unknown org or package, a package without
minion.app, or a members app seen by someone who isn't a member all get the same response, so an app's existence isn't revealed.
Limits
- Request body: 1 MB (413 above).
- Response: about 1 MB (binary bodies travel base64).
- Time: 25 seconds per request (504 after).
- Rate: 60 requests per minute per visitor, and 600 per minute per app (429 after).
- Errors: an app that throws gets a 502 with no stack trace.
Security model
Apps run on runminion.run, a separate domain from the Minion dashboard, and each organization gets its own subdomain.
- Your Minion login never reaches package code. The dashboard's cookies belong to a different domain. A members app has its own session cookie that only authorizes that one app for that one person, and it ends when they sign out of Minion.
- Organizations are kept apart. Each org's apps have their own origin, so a page in one organization can't read another organization's apps as the visitor.
- No page sandbox needed. Because the page has its own origin, a members app's own
fetch()calls and POST forms carry its session normally: interactive members apps work. - Cross-site writes are refused. A request that carries an app session must come from the app's own pages unless it's a safe method (GET, HEAD, OPTIONS). Your app also receives the
originandsec-fetch-*headers, so it can refuse state-changing GET requests itself. - Request headers are filtered before they reach your app. Cookies and
Authorizationnever do. - Responses can't set cookies. Only these response headers pass through:
content-type,content-language,content-disposition,cache-control,etag,last-modified,expires,vary,location,content-security-policyandx-request-id. - Redirects must stay inside the app. A
Locationthat points outside the app's own path is dropped, and a redirect without a valid location becomes a 502. - Members apps are never cached: their responses always get
cache-control: private, no-store.
Members of your organization also get a Server-Timing header on every response (lookup, access, ratelimit, usage, run-start, prepare, bundle, sandbox, run-finish, total), which shows where a slow request spent its time. Anonymous visitors of a public app don't get it.
What's in the sandbox
Available: Request, Response, Headers, URL, URLSearchParams, FormData (URL-encoded bodies), TextEncoder, TextDecoder, Intl (date and number formatting), atob, btoa, timers, AbortController, and crypto (getRandomValues, randomUUID, and crypto.subtle digest and HMAC).
Not available yet: streaming responses, multipart uploads, WebSockets, and other crypto.subtle algorithms.
Check it after publishing
Open the package's page in Minion and use Open app, or request a public app directly:
curl -i https://acme.runminion.run/guestbook/api/names Then look at the run: minion.runs.list({ source: 'app', limit: 5 }). A 502 means the app threw; the run's error has the message. A members app needs a signed-in browser, so check it in the browser rather than with curl.