> ## Documentation Index
> Fetch the complete documentation index at: https://dev-doc.dualmindlab.tech/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> These are the DualMind developer and admin docs. The API base URL is https://api.dualmindlab.tech and the local API runs on http://localhost:5079.
> Never send POST, PUT, PATCH or DELETE requests to the production API while developing; use a local API and a development Supabase project.
> Environment variables are documented by name only. Never ask for or print secret values.
> Admin routes under /api/admin require a Supabase access token for a user whose public.users.role is admin. The PUT routes for users, models, providers and threads write every field, so send the full record; PUT /api/admin/keys/{keyId} writes only the fields you send (sending api_key replaces the key).

# Cloudflare Containers

> How the dualmind-api Worker runs the ASP.NET Core API in a Cloudflare container: config, lifecycle, secrets and scaling.

<Panel>
  <Card title="At a glance" icon="container">
    * **Worker:** `dualmind-api`
    * **Container:** `standard-1`, port 8080
    * **Instances:** one (`default`), max 3
    * **Warm:** `sleepAfter` 120 min, cron every 5 min
    * **Domain:** `api.dualmindlab.tech`
    * **Deploys:** GitHub Actions on push to `main`
  </Card>

  <Card title="Check it" icon="activity" href="/deployment/status">
    Live API status from your browser.
  </Card>
</Panel>

Since August 2026 the API runs on **Cloudflare Containers** instead of Azure App Service. A small Worker (`cf-worker/`) owns the custom domain and forwards every request to the ASP.NET Core container through a <Tooltip tip="A Cloudflare primitive with a stable identity. With Containers, each Durable Object instance owns and starts one container.">Durable Object</Tooltip>.

```mermaid theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
flowchart TB
    C["Client"] -->|"https://api.dualmindlab.tech"| W["Worker: dualmind-api<br/>cf-worker/src/index.js"]
    CRON["Cron */5 * * * *"] --> W
    W -->|"getContainer(DUALMIND_CONTAINER, 'default')"| DO["Durable Object<br/>DualMindContainer (SQLite storage)"]
    DO --> CT["Container standard-1<br/>ASP.NET Core on :8080"]
```

## The pieces

<CodeGroup>
  ```js cf-worker/src/index.js icon="cloud" highlight={4-5,10} theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
  import { Container, getContainer } from "@cloudflare/containers";

  export class DualMindContainer extends Container {
    defaultPort = 8080;
    sleepAfter = "120m"; // stay warm for 2 hours after the last request
  }

  export default {
    async fetch(request, env, ctx) {
      const container = getContainer(env.DUALMIND_CONTAINER, "default");
      // abridged: the real handler also calls ensureTelegramWebhook() once per isolate
      return container.fetch(request);
    },
    async scheduled(controller, env, ctx) {
      const container = getContainer(env.DUALMIND_CONTAINER, "default");
      ctx.waitUntil(container.fetch(new Request("http://internal/health")));
    },
  };
  ```

  ```jsonc cf-worker/wrangler.jsonc (abridged) icon="settings" highlight={12-13,18-19} theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
  {
    "name": "dualmind-api",
    "main": "src/index.js",
    "vars": {
      "ASPNETCORE_ENVIRONMENT": "Production",
      "ASPNETCORE_URLS": "http://+:8080",
      "CLOUDFLARE_AI_GATEWAY_USE_BYOK": "true",
      "Telegram__ApiBaseUrl": "https://api.dualmindlab.tech",
      "Telegram__DeliveryMode": "Webhook"
    },
    "containers": [
      { "class_name": "DualMindContainer", "image": "../src/Dockerfile",
        "instance_type": "standard-1", "max_instances": 3 }
    ],
    "durable_objects": {
      "bindings": [{ "class_name": "DualMindContainer", "name": "DUALMIND_CONTAINER" }]
    },
    "triggers": { "crons": ["*/5 * * * *"] },
    "routes": [{ "pattern": "api.dualmindlab.tech", "custom_domain": true }]
  }
  ```
</CodeGroup>

| Setting | Value | Why |
| :- | :- | :- |
| Image | `src/Dockerfile` | .NET 8 Alpine runtime, ReadyToRun publish for faster cold starts |
| Instance type | `standard-1` | |
| `max_instances` | 3 | Ceiling for the container class; the Worker uses one named instance today |
| `sleepAfter` | 120 minutes | Keeps the container warm between bursts |
| Cron | every 5 minutes | Pings `/health` so the container rarely sleeps |
| Domain | `api.dualmindlab.tech` (custom domain) | |

## Secrets and variables

Plain settings live in `vars` in `wrangler.jsonc`. Secrets are set on the Worker with `wrangler secret put` by the deploy workflow, from GitHub Actions secrets. The names it configures:

`SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `JWT_SECRET`, `GROQ_API_KEY`, `GOOGLE_API_KEY`, `TELEGRAM_BOT_TOKEN`, `CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID`, `CLOUDFLARE_AI_GATEWAY_ID`, `CLOUDFLARE_AI_GATEWAY_TOKEN`, `CLOUDFLARE_WORKERS_AI_API_TOKEN`.

The workflow itself also needs `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` to run `wrangler`.

<Note>
  Names differ between the Worker and the API. The Worker reads `TELEGRAM_BOT_TOKEN` to set the webhook, while the API's bot reads `Telegram__BotToken`, and Telegram session encryption reads `APP_SECRET`. When you enable the bot in an environment, check that the API itself receives `Telegram__BotToken` and `APP_SECRET`; the bot stays off without a token.
</Note>

<Warning>
  Rotate a secret by updating the GitHub Actions secret and re-running the deploy, or with `npx wrangler secret put NAME` in `cf-worker/`. Don't put values in `vars`, in `wrangler.jsonc` or in this documentation.
</Warning>

## Scaling

<Steps>
  <Step title="13 August 2026: lazy multi-instance">
    The Worker switched to spreading requests across three instances with a random selector, and `/health` started returning `instanceId` (the Durable Object ID) so load tests could see which instance answered.
  </Step>

  <Step title="14 August 2026: back to one instance">
    The Worker went back to the single `default` container the next day. `instanceId` stayed in `/health`.
  </Step>
</Steps>

The change was one line in the Worker, plus the instance count:

```js cf-worker/src/index.js (13 Aug, reverted 14 Aug) icon="git-compare" theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
import { Container, getContainer } from "@cloudflare/containers"; // [!code --]
import { Container, getRandom } from "@cloudflare/containers"; // [!code ++]
const INSTANCE_COUNT = 3; // must match max_instances // [!code ++]

const container = getContainer(env.DUALMIND_CONTAINER, "default"); // [!code --]
const container = getRandom(env.DUALMIND_CONTAINER, INSTANCE_COUNT); // [!code ++]
```

Running several instances safely needs the in-memory state shared first: the five-minute model cache and the provider key pool's cooldowns and last-used times. Until then keep `getContainer(…, "default")`.

```bash Which instance answered? theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
curl -s https://api.dualmindlab.tech/health
# { "status": "healthy", "version": "1.0.0", "instanceId": "67c5…" }
```

## Operate it

| Task | Command (in `cf-worker/`) |
| :- | :- |
| Check config without deploying | `npx wrangler deploy --dry-run` |
| Tail logs | `npx wrangler tail dualmind-api` |
| List versions | `npx wrangler deployments list` |
| Roll back the Worker | `npx wrangler rollback` |
| Run the Worker locally | `npx wrangler dev` (needs Docker for the container) |

## Before Cloudflare: Azure

Until August 2026 the API ran on Azure App Service, deployed by an Azure publish-profile workflow. That pipeline was removed when the Cloudflare deploy went live. Older notes that mention Azure, `master_dualmind-arena.yml` or Bytez are out of date.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.