# Custom Bindings

Quickback's compiler owns the D1/KV/R2/ASSETS/queue blocks it needs for its own features. Anything else your worker binds to — Cloudflare Email, an extra R2 bucket, a custom queue, a service binding, your own Durable Object, Workers AI, non-secret `[vars]`, or a custom `secret` your code reads off `env` — goes under the top-level `bindings` key:

```ts
import { defineConfig, defineRuntime, defineDatabase, defineAuth } from "@quickback-dev/cli";

export default defineConfig({
  name: "studio-mail",
  template: "hono",
  providers: {
    runtime: defineRuntime("cloudflare"),
    database: defineDatabase("cloudflare-d1", { splitDatabases: true }),
    auth: defineAuth("better-auth"),
  },
  bindings: {
    sendEmail: [{ name: "EMAIL" }],
    r2Buckets: [
      { binding: "MAIL_RAW",         bucketName: "studio-mail-raw" },
      { binding: "MAIL_ATTACHMENTS", bucketName: "studio-mail-attachments" },
    ],
    vars: {
      MAIL_SENDING_DOMAIN: "studio.example.com",
    },
    secrets: [
      { name: "MAIL_INBOUND_SECRET", description: "Verifies inbound mail webhooks", required: true },
    ],
  },
});
```

camelCase field names map 1:1 to the snake_case TOML blocks in `wrangler.toml`. Every compile regenerates `wrangler.toml` from your config, so these entries are the source of truth — no more post-compile patch scripts.

## Fields

### `sendEmail`

Emits `[[send_email]]`. Use when your worker calls `env.<NAME>.send(...)` via Cloudflare Email Service.

```ts
sendEmail: [
  {
    name: "EMAIL",
    // Optional: restrict who the worker is allowed to send to.
    destinationAddress: "alerts@example.com",
    allowedDestinationAddresses: ["alerts@example.com", "ops@example.com"],
  },
]
```

### `r2Buckets`

Emits additional `[[r2_buckets]]` blocks alongside the compiler-owned files bucket.

```ts
r2Buckets: [
  {
    binding: "MAIL_RAW",
    bucketName: "studio-mail-raw",
    previewBucketName: "studio-mail-raw-preview", // optional
    jurisdiction: "eu",                           // optional
  },
]
```

### `queueProducers` / `queueConsumers`

Emits `[[queues.producers]]` and `[[queues.consumers]]`. Useful for app-owned queues beyond the compiler's webhook/embedding queues.

```ts
queueProducers: [
  { binding: "MAIL_QUEUE", queue: "mail-send" },
],
queueConsumers: [
  {
    queue: "mail-send",
    maxBatchSize: 5,
    maxBatchTimeout: 30,
    maxRetries: 3,
    maxConcurrency: 2,
    deadLetterQueue: "mail-dlq",
  },
]
```

### `services`

Emits `[[services]]` — service bindings to sibling workers.

```ts
services: [
  { binding: "INBOUND", service: "studio-mail-inbound", environment: "production" },
]
```

### `durableObjects`

Emits `[[durable_objects.bindings]]` for app-owned Durable Objects. Don't use this for Quickback's built-in `BROADCASTER` realtime DO — that one is compiler-owned.

A DO entry has two flavors: **in-worker** (the class lives in this worker — set `from`) or **cross-worker** (the class lives in another worker — set `scriptName`). The two are mutually exclusive.

```ts
durableObjects: [
  // In-worker DO — Quickback re-exports the class from src/index.ts
  // and emits a [[migrations]] block. Class file lives at
  // quickback/features/loops/lib/ItemStream.ts (copied verbatim to src/).
  { name: "ITEM_STREAM", className: "ItemStream", from: "features/loops/lib/ItemStream" },

  // Cross-worker DO — the exporting worker owns the migration; this
  // worker only declares the binding.
  { name: "RATE_LIMITER", className: "RateLimiter", scriptName: "rate-limit-worker" },
]
```

Each in-worker DO entry accepts:

| Field | Default | Notes |
|-------|---------|-------|
| `name` | — | Wrangler binding name. Becomes `c.env.<NAME>` (typed as `DurableObjectNamespace`). |
| `className` | — | The exported TypeScript class. |
| `from` | — | Module path under `src/` (no `./`, no `.ts`). The compiler copies `quickback/lib/...` and `quickback/features/<f>/lib/...` verbatim into `src/`, so use that same relative path. |
| `migrationTag` | `qb-do-${className}` | Wrangler migration tag. Stable across regenerations — **never remove or rename a deployed tag**, or wrangler will reject the deploy. |
| `useSqlite` | `true` | SQLite-backed DO storage. Set to `false` for the legacy KV-backed mode. |
| `scriptName` | — | Cross-worker only. Mutually exclusive with `from`. |

Where the class file lives: in-worker DO classes go under `quickback/lib/` or `quickback/features/<feature>/lib/` so they survive every compile (the rest of `src/` is wiped). Both copy verbatim into the generated `src/` tree.

### `durableObjectMigrations`

Cloudflare's `[[migrations]]` list is **append-only** — once a tag is deployed, removing it from `wrangler.toml` causes the next deploy to fail with code 10061 ("Cannot apply migration"). Quickback regenerates `wrangler.toml` from your config on every compile, so any historical lifecycle entries (deleted, renamed, transferred classes) need to live in the config too — otherwise they'd be wiped.

`bindings.durableObjectMigrations[]` is the slot for those entries. Compiler emits them **first** in the [[migrations]] list, before the auto-emitted current-class creation tags, so CF sees the chronology it needs.

```ts
bindings: {
  durableObjects: [
    { name: "NOTEPAD", className: "Notepad", from: "lib/Notepad" },
  ],
  // Historical lifecycle: a previous LegacyNotes class was deleted on
  // 2026-05-09. Without this entry, a recompile would drop the
  // deleted_classes block and the next deploy would 10061.
  durableObjectMigrations: [
    {
      tag: "qb-do-deleted-legacy-notes-2026-05-09",
      deletedClasses: ["LegacyNotes"],
    },
  ],
}
```

Each entry accepts:

| Field | Notes |
|-------|-------|
| `tag` | Migration tag. Once deployed, never change it. Convention: `qb-do-<verb>-<class>-<YYYY-MM-DD>`. |
| `newClasses` | KV-backed creations. Rare — most DOs use `new_sqlite_classes`. |
| `newSqliteClasses` | SQLite-backed creations. Use this if you ever need to declare a creation manually instead of letting the auto-emit handle it. |
| `deletedClasses` | Array of class names to tear down. **Destroys the DO's storage** on deploy — only use when you're sure. |
| `renamedClasses` | `[{ from, to }]` — class rename without losing storage. |
| `transferredClasses` | `[{ from, from_script, to }]` — move a class across workers. |

**When you remove a DO from `bindings.durableObjects`**, declare the teardown here. Don't hand-edit `wrangler.toml` — those edits get wiped on the next compile.

### `vars`

Merged into the compiler's own `[vars]` table — emitting a second `[vars]` block would be invalid TOML. Values must be `string`, `number`, or `boolean`.

```ts
vars: {
  MAIL_SENDING_DOMAIN: "studio.example.com",
  MAIL_MAX_ATTACHMENTS: 10,
  MAIL_DEBUG: false,
}
```

Secrets do **not** go here. Declare them under [`secrets`](#secrets) (name only) and set the value with Wrangler's secret store:

```bash
wrangler secret put MAIL_INBOUND_SECRET
```

### `secrets`

Declares a custom secret your feature or action code reads off `env`. You record the **name and contract only** — the value is never stored in config, `wrangler.toml`, or git.

```ts
secrets: [
  {
    name: "WEBHOOK_SIGNING_SECRET",
    description: "HMAC secret for outbound webhook callbacks",
    required: true,
  },
  { name: "PARTNER_API_KEY" }, // optional
]
```

Each declared secret:

- is emitted into the generated `env.d.ts` `Env` interface as an optional `WEBHOOK_SIGNING_SECRET?: string`, so `env.WEBHOOK_SIGNING_SECRET` type-checks in your code;
- when `required: true`, joins the **boot-time env guard** — the worker fails closed with a `503 MISSING_ENV` if the secret is unset, rather than 500ing on the first request that reads it (same posture as `BETTER_AUTH_SECRET`);
- surfaces its `description` and a `wrangler secret put <NAME>` hint in the generated type's JSDoc.

| Field | Type | |
| --- | --- | --- |
| `name` | `string` | Env var name. Letters, digits, underscores only — `SCREAMING_SNAKE_CASE` by convention. |
| `description` | `string?` | Human note shown in the JSDoc + secret-setup hint. |
| `required` | `boolean?` | Fail closed at boot if unset. Default `false`. |

Set the value out-of-band — it lands in Cloudflare's encrypted secret store:

```bash
wrangler secret put WEBHOOK_SIGNING_SECRET
```

> `secrets` records a **contract**, not a value. The schema rejects any inline value key, so a real secret can never be pasted into committed config. Use [`vars`](#vars) for non-secret values that are safe to commit.


### `ai`

Emits a top-level `[ai]` block. Use this if you need Workers AI bound under a name different from Quickback's internal `AI`.

```ts
ai: { binding: "USER_AI" }
```

## Reserved binding names

Binding names that clash with compiler-owned bindings are rejected at validation time, with the offending field path in the error message. Reserved names depend on what your config enables:

| Name | Reserved when |
|------|---------------|
| `AUTH_DB`, `DB` | `splitDatabases: true` (default) |
| `DATABASE` | `splitDatabases: false` |
| `AUDIT_DB` | always (cross-tenant unsafe-action audit) |
| `WEBHOOKS_DB`, `WEBHOOKS_QUEUE` | a webhooks binding is configured |
| `KV` | always |
| `R2_BUCKET`, `FILES_DB` | **managed** file storage — `defineFileStorage("cloudflare-r2", { managed: true })`. Presign-only R2 (the default) emits no bucket binding, so both names stay free for you to declare here. |
| `VECTORIZE` | a Vectorize index is configured |
| `BROADCASTER` | realtime is enabled |
| `EMAIL` | `email: { provider: "cloudflare" }` |
| `ASSETS` | `cms: true` or `account: true` |
| `AI`, `EMBEDDINGS_QUEUE` | embeddings are configured |

Pick a different name — the generated `env` on the handler side takes whatever binding you pick.

## One name, one thing

Bindings and vars share a single namespace: `env.DB` is one slot, whatever
declares it. A name claimed twice within one deploy target fails the compile,
naming both claimants and what each points at:

```
Two declarations claim the same name on env: "MEDIA" is claimed by
[r2_buckets] (studio-media) and by [r2_buckets] (studio-uploads) in the
top-level config.
```

This catches what the reserved-name table above cannot — that compares your
names against compiler-owned ones, never against each other:

- two of your own declarations sharing a name (`r2Buckets`, `queueProducers`,
  `services`, `durableObjects`);
- a [`vars`](/configure/variables) key shadowing a binding — the easy one to
  miss, since a var looks nothing like a binding but lands on the same `env`;
- collisions inside a named environment's `[env.*]` stanza.

Scoped **per target**, so [named environments](/platform/database/d1#named-cloudflare-deployment-environments)
are unaffected: every target declares the same binding names pointing at its
own resources, and `ASSETS` is emitted at the top level and in each stanza by
design. Two identical declarations are redundant rather than ambiguous and are
allowed — only a name pointing at two *different* things is rejected.

## See also

- [Environment Variables](/configure/variables) — the non-secret `[vars]` / secret split
- [Providers](/configure/providers) — runtime / database / auth selection
