Context (ctx)
The second argument of the handler is ctx. Each member is present only when the release requests the capability in its manifest (permissionsRequested) and an administrator has granted it. Otherwise the member is undefined: check before use.
| Member | Capability | Requested with | Granted by |
|---|---|---|---|
ctx.db |
Private SQLite database | permissionsRequested.database |
Site admin, Developer, workspace admin, Super Admin |
ctx.storage |
Private file storage | permissionsRequested.storage |
same |
ctx.env |
Site secrets | permissionsRequested.secrets |
same |
ctx.mail |
Outbound mail | permissionsRequested.email |
Super Admin only |
fetch (global) |
Outbound HTTPS | permissionsRequested.externalHttp (host list) |
Super Admin only |
ctx.dbs |
Named workspace databases | permissionsRequested.workspaceDatabases |
coarse switch plus a per-database grant |
ctx.admin |
Admin extension verification | (private /_admin/ calls only) |
not a grant |
Grants are managed under Settings, Permissions in the site admin, or with GET/PATCH /admin/api/v1/sites/{id}/capabilities (the PATCH body carries expected_policy_version and a set object; a stale version is rejected). Grant changes apply to the next broker call, with no redeploy. A grant never raises a quota or supplies a secret value. See Manifest for the intersection rule.
Presence of a member does not guarantee a call succeeds: each call is re-authorized, and quotas, resource availability and sender configuration can still refuse it. Calls reject with a coded error (see Limits and errors).
ctx.db: raw SQL
ctx.db.query(sql: string, params?: SqlParam[]): Promise<SiteDbResult>
ctx.db.execute(sql: string, params?: SqlParam[]): Promise<SiteDbResult>
ctx.db.transaction(statements: { sql: string; params?: SqlParam[] }[]): Promise<SiteDbResult[]>
// SiteDbResult = { columns: string[]; rows: unknown[][]; rowsAffected: number; lastInsertRowid: number | bigint | null }
queryis for reads. The broker refuses a statement that modifies data; useexecuteforINSERT,UPDATE,DELETE.executealso returns the rows of a statement withRETURNING.transactionruns the statements all-or-nothing and resolves with one result per statement. A failure rolls everything back. There are no interactive transactions across calls.- Rows are arrays, in column order, not objects. For object rows use the Database SDK.
- Parameters (
?placeholders):nullandundefinedbecome NULL; booleans become 0 or 1; safe integers become INTEGER; other finite numbers become REAL (NaNandInfinitythrowTypeError); bigints in the signed 64-bit range become INTEGER; strings become TEXT;Uint8ArrayandArrayBufferbecome BLOB. Anything else throwsTypeError. - Results: INTEGER is a number when it is a safe integer, otherwise a
bigint(convert beforeJSON.stringify); REAL is a number; TEXT a string; BLOB aUint8Array; NULL isnull. - Always bind values with parameters. Never concatenate visitor input into SQL.
What the broker enforces (these are SQLite hardening rules, independent of your code):
- Tenant SQL cannot change the schema:
CREATE,DROP,ALTER,PRAGMA,ATTACH,ANALYZE, virtual tables and temporary objects are refused. Schema changes belong in migrations. - Tables whose names begin with
_mh_are reserved for the host. - Only an allowlist of SQL functions is available: core scalar functions (including
random,randomblob,printf,like,coalesce), aggregate and window functions, the date functions (date,datetime,strftime,unixepoch, ...), and JSON1. Anything else, such asload_extension, is refused. - Per call: SQL text at most 64 KiB, at most 999 bound parameters, a query or execute runs at most 2 s, a transaction at most 5 s and at most 256 statements, at most 4 concurrent connections per site, strings and blobs at most 16 MiB.
- Every call counts toward the execution's host-call budget and its database-row budget (see Limits).
ctx.storage: private files
ctx.storage.get(key: string): Promise<Uint8Array | null>
ctx.storage.put(key: string, value: string | Uint8Array): Promise<void>
ctx.storage.append(key: string, value: string | Uint8Array): Promise<void>
ctx.storage.delete(key: string): Promise<boolean>
ctx.storage.list(prefix: string, options?: { limit?: number }): Promise<string[]>
ctx.storage.sendFile(key: string, init?: { headers?: HeadersInit }): Response
Keys are safe relative paths: no leading
/, no backslash, no empty,.or..segments, at most 1,024 bytes.putstores strings as UTF-8.deleteresolvestruewhen something was removed.listreturns sorted names under the prefix, at mostlimit(1 to 1000, default 1000), with no cursor.appendadds to the end of an object and creates it when missing. It is atomic: concurrent appends never lose bytes or interleave inside one call, so it is safe for logs and counters-as-lines. It counts the appended bytes against the per-execution write limit and the site quota, and the object may not grow past 16 MiB (INVALID_INPUT).sendFileis for downloads of any size. It returns a response that makes the platform stream the object to the visitor withRange,ETagand304; your code only decides who may download. Headers you set (Content-Type,Content-Disposition,Cache-Control, cookies) are kept;Content-Length,ETag,Content-RangeandAccept-Rangesare the platform's. A missing object answers 404, and you can only name your own site's objects. Call it from the handler'sreturn, after your own authorization check:if (!(await isLoggedIn(request, ctx))) return new Response("Forbidden", { status: 403 }); return ctx.storage.sendFile(`reports/${id}.pdf`, { headers: { "content-type": "application/pdf", "content-disposition": "attachment" }, });Where an object lives is the platform's business. When the workspace or site has an external store (see the operator guide), large objects may be kept there;
get,put,append,deleteandlistbehave identically and you cannot tell. Agetis still limited by the host-call size (about 2.7 MiB per call), so usesendFileto deliver big objects. Anappendto an object that was kept externally first brings it back to local disk (the object is at most 16 MiB), then appends atomically.Storage is private data. It cannot publish files into
public/, andpublic/files are not reachable through it.Limits: 16 MiB written per execution; the site quota (256 MiB and 10,000 objects by default) applies across executions.
Do not build a public write endpoint around visitor-chosen keys without validation and authorization.
const bytes = await ctx.storage.get("content/about.txt");
if (bytes === null) return new Response("Not found", { status: 404 });
return new Response(bytes, { headers: { "content-type": "text/plain; charset=utf-8" } });
ctx.env: secrets
ctx.env.get(name: string): Promise<string | undefined>
Reads a site secret that an administrator stored (PUT /admin/api/v1/sites/{id}/secrets/{name}). It is not the process environment. An unknown or reserved name resolves to undefined. Secret values are redacted from console output. Never return a secret to the browser and never put one in a manifest.
ctx.mail: outbound mail
ctx.mail.send({ to: string | string[], subject: string, text: string, html?: string }): Promise<{ messageId: string }>
send resolves when the message is accepted into the durable outbox, not when it is delivered. The sender identity is the site's, not chosen by your code; there are no attachments. Policy limits apply (by default at most 10 messages per execution, 10 recipients and 256 KiB per message; the outbox holds at most 100 queued messages and 5 MiB per site). Delivery is asynchronous, retried and expires after 24 hours; operators see its state on the site Activity page.
Status. Messages are delivered through the platform's SMTP server, which a platform administrator sets up under Platform settings → Mail. Until then the mail capability cannot be granted to a site (
mail_not_configured). Mail is alwaysFromthe address configured there (by default the platform's, or the site's own SMTP server if the owner set one under Site settings → Mail); your code cannot choose it. Mail is not part of the conformance suite.
ctx.jobs: background jobs
ctx.jobs.enqueue(name: string, payload?: unknown, options?: { delaySeconds?: number; key?: string }): Promise<{ runId: number }>
Present only when the site declares jobs in its manifest and an administrator granted it jobs. It queues a run of one of your own jobs; see Jobs for the payload, the idempotency key and the error codes.
fetch: outbound HTTPS
fetch(input: string | URL, init?: { method?: string; headers?: HeadersInit; body?: string | Uint8Array }): Promise<FetchResponse>
// FetchResponse: status, ok, headers, text(), json(), arrayBuffer()
- Absolute
https:URLs only. The host must be on the site's allowlist: the release requests exact hostnames inpermissionsRequested.externalHttp(at most 32), and a Super Admin grants them. Port 443 only, no wildcards or subdomains. - The broker checks destination, DNS and IP rules, and every redirect hop (at most 3), and re-checks the grant on each call.
- Bodies are buffered. Production composition caps request and response bodies at 1 MiB each. There is no
AbortSignal, no streaming, no cookie jar. - A single call is bounded by the broker (5 s connect, 30 s total, 15 s host-call timeout), and the execution's own wall-time limit still applies.
- The response object is a
FetchResponse, not aResponse.
Status. In the production composition
fetchuses the in-process HTTPS egress broker. The separate egress process (RemoteEgressBroker) is not built and returnsUnimplemented;fetchis proven end to end on all three engines against a local TLS peer (allowed host, refused host, plain HTTP and IP literals), but it is not in the cross-engine conformance suite and has not been run against a real public host. Test it on your own host before depending on it.
ctx.dbs: named workspace databases
When a release requests permissionsRequested.workspaceDatabases: ["catalog"], the coarse workspace_databases capability is granted, and a live catalog grant exists for that database, ctx.dbs.get("catalog") returns a handle with query, execute and transaction (same shapes as ctx.db). Names are 1 to 64 letters, digits, underscores or hyphens; a release may request at most 64.
Status. The runtime surface exists, but it is not declared in
sdk/microhost.d.ts, the SDK modules (microhost/db) do not wrap it, and project notes record its integration as unverified. Usectx.dbunless you have to share data across sites.
ctx.admin: private extension calls
Present only on private /_admin/ invocations dispatched by the control plane. Use the global verifyAdminRequest(request, ctx) rather than touching it. See Admin extensions.