Bindings and primitives
This page lists every Cloudflare binding a Mantle Worker commonly declares: which are required, what Mantle does with each, the wrangler.jsonc snippet, and the matching Env field. Read it when you write or review a Worker's configuration.
Compatibility flags
Every Mantle Worker keeps both flags:
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"]nodejs_compat is required by the adapter and its Auth dependencies. global_fetch_strictly_public is required because MCP client metadata (CIMD) is fetched through the public Internet boundary.
The composition-root rule
Bindings appear in two places only: the Worker entry (createMantleWorker options and the bindings hook) and wrangler.jsonc. Procedure handlers receive them through ctx.env. An application may own additional tables behind its own repository, but it never queries Mantle-owned native Schema tables, site settings, media or Auth tables outside the runtime. Use runtime.entries, generated bindMantle(runtime) and Views instead.
The Env interface
import type { DeferredHookEnvelope } from "@aotter/mantle/runtime";
import type { MantleCloudflareEnv } from "@aotter/mantle/cloudflare";
interface EmailBinding {
send(message: { to: string; from: string; subject: string; text?: string; replyTo?: string }): Promise<unknown>;
}
export interface Env extends MantleCloudflareEnv {
// MantleCloudflareEnv already declares DB, ASSETS?, MANTLE_KV? and the auth vars.
readonly DB: D1Database; // required
readonly ASSETS: Fetcher; // Admin bundle + your frontend
readonly PUBLIC_ORIGIN: string;
readonly MANTLE_KV?: KVNamespace; // optional MCP catalog cache
readonly MEDIA_BUCKET?: R2Bucket; // optional media
readonly R2_ACCOUNT_ID?: string;
readonly R2_ACCESS_KEY_ID?: string;
readonly R2_SECRET_ACCESS_KEY?: string;
readonly MEDIA_PUBLIC_URL_BASE?: string;
readonly AUDIT?: AnalyticsEngineDataset; // optional MCP tools/call audit trail
readonly MANTLE_INTERNAL_QUEUE?: Queue<DeferredHookEnvelope>; // optional deferred hooks
readonly ORDER_EXPIRY_QUEUE?: Queue<{ type: "expire-order"; orderToken: string }>; // application queue
readonly INVENTORY_COORDINATOR?: DurableObjectNamespace; // application-owned
readonly EMAIL?: EmailBinding; // Email Service
readonly TURNSTILE_SECRET_KEY?: string; // secret
}D1: DB (required)
D1 is canonical storage. It holds one native table per Manifest Schema, Mantle's _mantle_* envelope and migration metadata, site settings, media metadata and pending uploads, and the Better Auth tables. createConventionalBindings throws without DB.
"d1_databases": [
{ "binding": "DB", "database_name": "my-site", "database_id": "<production-id>" }
]Omit database_id only for local development. Set the production id before any remote deploy.
Static Assets: ASSETS
"assets": { "directory": "./public", "binding": "ASSETS" }ASSETS is a hard requirement for Admin. mantle generate syncs the prebuilt Admin SPA into public/_mantle/admin/ when @aotter/mantle-admin-ui is installed — there is no Vite step unless you are developing admin-ui itself. The facade wraps ASSETS as the Admin asset server and falls back to /_mantle/admin/index.html for client-side Admin routes. The same binding serves your own CSS, JS and icons.
Without this binding, /admin can still return SPA HTML (200) while /_mantle/admin/assets/* is 404. That white-screens the Dev UI.
When Worker routes and static paths overlap, list Worker-owned paths in run_worker_first and keep not_found_handling at "none" so unmatched requests fall through to the Worker. Leave /_mantle out of run_worker_first so hashed Admin JS/CSS stay on the assets layer. A production configuration lists /admin, /admin/*, /mcp, /mcp/*, /oauth, /oauth/*, /.well-known/*, /api/*, /llms.txt, /robots.txt, /sitemap.xml, /*/llms.txt and every public content prefix there, so a static file can never shadow a Mantle route.
Workers Cache
"cache": { "enabled": true }With a valid cacheScope, Mantle marks anonymous 200 GET/HEAD responses from the public mount (HTML, .md, llms.txt, sitemap) and eligible Views with a shared lifetime and a deployment-scoped Cache-Tag; everything else is private, no-store. Supplying a custom credentialResolver disables shared responses because Core cannot infer which request headers carry identity. Publishing-content and site-setting writes purge the tag after the canonical write. Purge failure is logged and does not turn a committed write into an error. The local emulator does not simulate the entrypoint cache or its purge API. See Public web.
KV: MANTLE_KV (optional)
"kv_namespaces": [
{ "binding": "MANTLE_KV", "id": "<production-id>", "preview_id": "<development-id>" }
]A deployment-owned namespace that caches the caller-independent MCP catalog projection (brand, description, origin, icons, media-purpose policy) so an authenticated MCP catalog does not read D1 site settings. D1 stays canonical; missing or expired snapshots are repaired from D1 within one hour, and a KV failure never fails a committed write. Never store tokens, sessions or content here. createMantleWorker uses this binding only when cacheScope is valid, producing keys such as mantle:my-site-production:site-config:v1:mcp. KV is eventually consistent across regions, so invalidation is not a global read-after-write guarantee.
R2: MEDIA_BUCKET (optional)
"r2_buckets": [{ "binding": "MEDIA_BUCKET", "bucket_name": "<project>-media" }]Staff media uploads through Staff MCP presigned PUT. The binding alone cannot sign URLs; you also need R2_ACCOUNT_ID, MEDIA_PUBLIC_URL_BASE and the two S3 credential secrets. See Media uploads with R2.
Analytics Engine: AUDIT (optional)
"analytics_engine_datasets": [{ "binding": "AUDIT", "dataset": "mantle_mcp_audit" }]An MCP tools/call audit trail: who called which tool on which surface, with what outcome and how long it took. The runtime's McpJsonRpcDispatcher is the single write point; it records nothing unless the composition root passes an AuditSink, and the write happens through waitUntil, off the response path. Request and response payloads are never recorded.
import { analyticsEngineAuditSink, createMantleWorker } from "@aotter/mantle/cloudflare";
export default createMantleWorker<Env>({
plan,
audit: (env) => env.AUDIT
? analyticsEngineAuditSink(env.AUDIT, { index: env.PUBLIC_ORIGIN })
: undefined,
});One dataset may hold many deployments: index is the deployment's public origin, the only column Analytics Engine filters cheaply. Columns, in order:
| Column | Field |
|---|---|
index1 | the configured index |
blob1 … blob7 | surface, callerId, clientId, credential, tool, outcome, operationId |
double1, double2 | at (epoch ms), durationMs |
outcome is ok, a runtime Diagnostic code such as UNAUTHENTICATED or AUTH_DENIED, UNKNOWN_TOOL, INVALID_PARAMS, INTERNAL, or a transport gate denial (INVALID_TOKEN, INSUFFICIENT_SCOPE, INSUFFICIENT_ROLE, CROSS_ORIGIN): a tools/call refused before it reaches the dispatcher is recorded too, with whatever identity the gate established. Its body goes through the same 1 MiB bounded reader as an admitted call; a denied request whose body is oversized or not JSON is still recorded, with tool set to (unreadable), so padding a body cannot hide a credential probe. operationId is the string argument whose schema declares x-mcp-hint: idempotency-key, falling back to the conventional operationId argument when the tool declares no hint. It is empty when that argument is absent and is resolved the same way on admitted and denied calls. Read it with the SQL API:
SELECT timestamp, blob2 AS caller, blob5 AS tool, blob6 AS outcome, double2 AS ms
FROM mantle_mcp_audit
WHERE index1 IN ('https://site-a.example', 'https://site-b.example')
AND timestamp > NOW() - INTERVAL '7' DAY
ORDER BY timestamp DESCAnalytics Engine keeps data for a bounded window and samples under very high write rates. Longer retention or a tamper-evident trail is an export job on top of this dataset. Self-hosted runtimes may implement AuditSink over any store; the interface is one record(event) method.
Queues
"queues": {
"producers": [
{ "binding": "MANTLE_INTERNAL_QUEUE", "queue": "mantle-internal" },
{ "binding": "ORDER_EXPIRY_QUEUE", "queue": "my-site-order-expiry" }
],
"consumers": [
{ "queue": "mantle-internal", "max_batch_size": 10, "max_batch_timeout": 5,
"max_retries": 5, "retry_delay": 60, "dead_letter_queue": "mantle-internal-dlq" },
{ "queue": "my-site-order-expiry", "max_concurrency": 1, "max_batch_size": 10,
"max_retries": 5, "retry_delay": 30, "dead_letter_queue": "my-site-order-expiry-dlq" }
]
}MANTLE_INTERNAL_QUEUE carries deferred after_* lifecycle hooks. Application queues share the same Worker; the queue() export switches on batch.queue. See Deferred hooks with Queues.
Cron Triggers
"triggers": { "crons": ["*/5 * * * *"] }A schedule Trigger declares the Procedure, and the Worker export reuses the same runtime as fetch:
const worker = createMantleWorker<Env>({ plan, handlers });
export default {
fetch: worker.fetch,
scheduled: worker.scheduled,
} satisfies ExportedHandler<Env>;Register the exact enabled cron expression in wrangler.jsonc; Mantle does not create provider triggers. The Procedure takes {} input and sees ctx.schedule.id for retry-safe idempotency. See Trigger.
Durable Objects (application-owned)
Durable Objects are not a Core primitive. Use one as an application-owned coordinator when a decision must be serialized, for example reserving inventory exactly once. Export the class from the Worker entry, then bind and migrate it:
"durable_objects": { "bindings": [{ "name": "INVENTORY_COORDINATOR", "class_name": "InventoryCoordinator" }] },
"migrations": [{ "tag": "inventory-v1", "new_sqlite_classes": ["InventoryCoordinator"] }]export { InventoryCoordinator } from "./commerce/InventoryCoordinator.js";The coordinator keeps its own state; Mantle entries mirror the result. See Commerce inventory.
Email Service: [[send_email]]
"send_email": [{ "name": "EMAIL" }]Send from an after_create handler with errorPolicy: continue, and fail soft when the binding or addresses are absent so submissions still save on a fresh deployment:
export async function notifyIntake(input: { name?: string; email?: string }, ctx: HandlerContext) {
const env = ctx.env as Env & { INTAKE_NOTIFY_TO?: string; INTAKE_NOTIFY_FROM?: string };
if (!env.EMAIL || !env.INTAKE_NOTIFY_TO || !env.INTAKE_NOTIFY_FROM) {
console.info("[intake] notification not configured");
return { ok: true };
}
await env.EMAIL.send({
to: env.INTAKE_NOTIFY_TO,
from: env.INTAKE_NOTIFY_FROM,
subject: `New intake response from ${input.name ?? "website"}`,
text: `Email: ${input.email ?? ""}`,
...(input.email ? { replyTo: input.email } : {}),
});
return { ok: true };
}Turnstile
Store the secret with wrangler secret put TURNSTILE_SECRET_KEY. Declare the token in the Procedure input, then verify it in a before_create lifecycle Trigger that aborts on failure:
apiVersion: cms.mantle.aotter.net/v1
kind: Trigger
metadata:
name: intake-before-create-verify-turnstile
spec:
source:
kind: lifecycle
schema: intake-submissions
on: [before_create]
errorPolicy: abort
target:
procedure: verify-intake-turnstileThe adapter exports a handler factory for the siteverify call:
import { cloudflareTurnstileCheck } from "@aotter/mantle/cloudflare";
extend: ({ env }) => ({
handlers: {
"verify-intake-turnstile": cloudflareTurnstileCheck({
secret: env.TURNSTILE_SECRET_KEY ?? "dev-stub",
tokenField: "turnstileToken",
}),
},
}),Authenticated callers (ctx.user set) bypass the check. The literal secret "dev-stub" skips the network and rejects only the token "fail". Rejection throws AUTH_DENIED, which maps to HTTP 403. See Intake Turnstile and email hooks.
Source
packages/adapters/cloudflare/README.mdpackages/adapters/cloudflare/src/bindings/conventionalBindings.tspackages/adapters/cloudflare/src/mount/cmsConfig.tspackages/adapters/cloudflare/src/worker/createMantleWorker.tspackages/adapters/cloudflare/src/handlers/turnstile.tspackages/mantle/src/cli/generate.tsdocs/deferred-lifecycle-queues.mddocs/media-uploads.mddocs/performance-harness.mddocs/cloudflare-low-level-composition.mddocs/examples/host-local-admin-otp/wrangler.jsoncdocs/examples/host-minimal-worker/wrangler.jsonc