Most of this page covers the Node.js runtime (@ixo/oracle-runtime). An oracle on the Cloudflare Workers runtime (@ixo/oracle-runtime-workers) deploys with wrangler deploy instead: see Cloudflare Workers runtime for the "minify": true setting and the 128 MB memory limit every user's turns run under.
Reference Dockerfile
QiForge has no runtime dependency on itself — anywhere Node 22+ runs, an oracle runs. This Dockerfile is the shortest path from pnpm dev to a container you can ship.
FROM node:22-slim AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
RUN mkdir -p /data
ENV MATRIX_STORE_PATH=/data/matrix-storage
ENV SQLITE_DATABASE_PATH=/data/sqlite
EXPOSE 3000
CMD ["node", "dist/main.js"]Everything below explains the moving parts. The reference oracle (apps/qiforge-example) builds with pnpm build and runs with node dist/main.js — see apps/qiforge-example/src/main.ts.
Prerequisites
- Node.js 22+
- A reachable Matrix homeserver
- A persistent volume for the Matrix store and SQLite checkpointer
- Core env vars (per
baseEnvSchema) - Optional: Redis (only if
creditsortasksplugins are loaded)
Build and ship
Build the production bundle
pnpm install --frozen-lockfile
pnpm buildFor the example app this runs tsc -p tsconfig.build.json and outputs dist/. Your package.json's start script should be node dist/main.js.
Pin every required env var
Production .env (or your platform's secret manager) needs every core var plus the vars for each plugin you've kept.
NODE_ENV=production
PORT=3000
ORACLE_NAME=My Oracle
CORS_ORIGIN=https://your-portal.example
NETWORK=mainnet
# Identity (validated by baseEnvSchema)
ORACLE_DID=did:ixo:ixo1...
ORACLE_ENTITY_DID=did:ixo:entity:...
SECP_MNEMONIC=...
RPC_URL=https://rpc.ixo.world
BLOCKSYNC_GRAPHQL_URL=https://blocksync.ixo.world/graphql
# Auth tuning (optional — safe defaults)
UCAN_AUTH_MAX_TTL_SECONDS=900 # max accepted lifetime of a user auth invocation (default 15 min)
UCAN_REAUTH_PROMPT_THROTTLE_SECONDS=21600 # gap between re-authorize prompts (default 6 hours)
# Matrix
MATRIX_BASE_URL=https://matrix.ixo.world
MATRIX_ORACLE_ADMIN_USER_ID=@my-oracle-bot:ixo.world
MATRIX_ORACLE_ADMIN_PASSWORD=...
MATRIX_ORACLE_ADMIN_ACCESS_TOKEN=...
MATRIX_ACCOUNT_ROOM_ID=...
MATRIX_VALUE_PIN=...
MATRIX_RECOVERY_PHRASE=...
MATRIX_STORE_PATH=/data/matrix-storage # MUST persist across restarts
# Storage
SQLITE_DATABASE_PATH=/data/sqlite
# LLM provider
LLM_PROVIDER=openrouter
OPEN_ROUTER_API_KEY=...
# or LLM_PROVIDER=nebius + NEBIUS_API_KEY
# Optional tracing
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=...
LANGSMITH_PROJECT=my-oraclePer-plugin vars come on top — see environment variables reference for the full list. Boot validates every var declared by every loaded plugin's configSchema; missing required vars fail loudly.
Run the entity setup once per deployment
Identity and Matrix keys are provisioned via the CLI — see identity and auth for the full flow.
qiforge-cli create-entity --no-interactive --network mainnet ...
qiforge-cli setup-encryption-keyPersistent volumes
Mount /data as a persistent volume
Two things must survive restarts. Lose either and you lose state.
| Path | What it stores | What breaks if you lose it |
|---|---|---|
MATRIX_STORE_PATH (default /data/matrix-storage) | Matrix sync state, room keys, decrypted cache | Full re-sync on next boot; potential decrypt issues |
SQLITE_DATABASE_PATH (e.g. /data/sqlite) | Per-user LangGraph checkpointer | Thread continuity across restarts |
In Docker Compose:
services:
oracle:
image: my-oracle:latest
environment:
- MATRIX_STORE_PATH=/data/matrix-storage
- SQLITE_DATABASE_PATH=/data/sqlite
volumes:
- oracle-data:/data
volumes:
oracle-data:On Railway / Fly.io: attach a persistent volume mounted at /data.
Add Redis only when needed
Redis is required only if you load the credits plugin (or the tasks plugin once it ships). Without those, skip it entirely.
services:
redis:
image: redis:7
volumes:
- redis-data:/data
oracle:
environment:
- REDIS_URL=redis://redis:6379
depends_on:
- redis
volumes:
redis-data:Pass the Redis client to CreditsPlugin explicitly — see enable bundled plugins.
Health probes
Use /health as the liveness probe
The framework exposes GET /health, always public, never goes through AuthHeaderMiddleware. Returns 200 once Nest is up. The built-in auth-excluded routes are / (a JSON landing payload), /health, /docs, and /docs/(.*) — everything else requires auth.
Docker:
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost:3000/health || exit 1Kubernetes:
livenessProbe:
httpGet:
path: /health
port: 3000There is no separate readiness probe
Matrix init runs in the background. The process accepts requests immediately after Nest boots. If you need to delay traffic until Matrix is up, subscribe via app.onPluginStatusChange and signal your platform when matrix transitions to loaded — see observability.
The /docs Swagger UI is also auth-excluded
Useful for debugging in production behind a private network. To remove it in public deployments, host-supply your own Nest module that re-mounts /docs behind auth, or block it at your reverse proxy.
CORS
CORS_ORIGIN controls who can call your oracle from a browser.
| Value | Behaviour |
|---|---|
* | Open. Credentials disabled (browsers require a specific origin to send credentials). |
https://your-portal.example | Specific origin; credentials: true enabled. |
The framework allows these headers on every request:
Content-Type, Authorization,
x-ucan-delegation, x-matrix-access-token, x-matrix-homeserver,
x-did, x-request-id, x-auth-type, x-timezoneAuthorization and x-auth-type carry the primary invocation-auth path (Authorization: Bearer <invocation> + X-Auth-Type: ucan); x-ucan-delegation carries the downstream-authorization delegation. If you replicate CORS at a reverse proxy, mirror this full list — dropping x-auth-type breaks browser clients on the primary auth path.
Logging
The runtime uses NestJS's default Logger. Pipe stdout/stderr to your aggregator. Override the bootstrap logger with createOracleApp({ logger }) if you need a custom format — see createOracleApp reference.
For structured tracing instead of free-form logs, set the LangSmith env vars — see observability.
Graceful shutdown
The runtime registers a SIGTERM / SIGINT handler that drains in order: (1) flushes the per-user checkpoint to Matrix, (2) closes the Nest app, (3) shuts down the Matrix client, (4) runs any plugin teardowns. Each step is isolated — a failure in one is logged and the rest still run.
That first step is why a clean SIGTERM matters: an abrupt SIGKILL skips the checkpoint upload and loses recent thread state. See graceful-shutdown.ts.
// To disable (rare — only when your platform's process manager
// owns signal handling and you've verified it calls app.close()):
const app = await createOracleApp({
config,
skipGracefulShutdown: true,
});Disable graceful shutdown only when your platform's process manager guarantees a clean app.close() on termination. Otherwise you'll skip the checkpoint flush and risk Matrix sync corruption.
Cloudflare Workers runtime
An oracle on @ixo/oracle-runtime-workers is a Cloudflare Worker that calls createOracleWorker. Each user gets a Durable Object (UserOracleDO), and that user's turns run in the object's isolate, which has 128 MB of heap. The bundle's code takes a large part of it before any turn runs. When a turn needs more than is left, Cloudflare resets the isolate (Durable Object's isolate exceeded its memory limit and was reset).
Two things keep long threads inside the limit, and a production oracle needs both: "minify": true in its wrangler config, and @ixo/oracle-runtime-workers 0.37.0 or later.
Set "minify": true (required)
// wrangler.jsonc
{
"name": "my-oracle",
"main": "src/index.ts",
"compatibility_date": "2026-07-15",
"compatibility_flags": ["nodejs_compat"],
"minify": true,
"keep_names": true
// durable_objects, migrations, alias, vars: see the runtime's example app
}"minify": true is required for a production oracle. The runtime ships TypeScript source, not a prebuilt bundle, so your wrangler deploy bundles it with your own code, and minifying is your setting: the package cannot set it for you. Without it the code is uploaded as written, and the isolate holds that larger code before it does any work. Measured on one production oracle:
| Bundle | Upload | Isolate heap before the first turn |
|---|---|---|
| Not minified | 35 MB | about 87 MB |
"minify": true | 21 MB | about 54 MB |
Without minify, that oracle's isolate was reset on a WhatsApp thread above about 90,000 tokens. With minify, the same thread worked.
Keep "keep_names": true. It is wrangler's default, so you only need to leave it on. Minifying renames classes and functions, and keep_names restores their names, which LangChain and the runtime's checkpoint serializer read.
minify is a top-level key, and named environments (env.<name>) inherit it unless they set their own. In a two-script layout, set it in the oracle script's config and the gateway script's config. wrangler deploy --dry-run prints the upload size (Total Upload) without deploying, so you can confirm that minify took effect.
The full wrangler config (Durable Object bindings, migrations, the alias entries the Matrix crypto and editor need, the cron trigger and the rate limiter) is in the runtime's example app, apps/qiforge-workers-example, and its docs/configuration.md, in the qiforge repository.
Optional: downscale images with Cloudflare Images
From @ixo/oracle-runtime-workers 0.38.0, the oracle can bind Cloudflare Images under the name IMAGES (in a two-script layout, on the oracle script):
// wrangler.jsonc
{
"images": { "binding": "IMAGES" }
}With the binding, an image sent natively to a model (in the turn, through view_attachment, and when recent images are put back into a model call) with either side over 1568 px is first downscaled to fit 1568 × 1568 px, as a JPEG at quality 82, so a phone photo rides in the request at a few hundred KB instead of several MB. The sandbox always keeps the original. JPEG, PNG, WebP and GIF (first frame) are resized; SVG, BMP, TIFF, HEIC and other types are sent as they are. An image the binding reports as malformed, one over 12,000 px a side or 50 megapixels, and one over 1568 px whose transformation fails are never sent natively. Without the binding, images are sent unchanged.
Cloudflare bills $0.50 per 1,000 unique transformations a month after the first 5,000, which are free; each downscaled image is one. wrangler dev runs a low-fidelity local version of the binding (no charges); wrangler dev --remote matches production.
Two-script layouts: deploy 0.38.0 to both scripts back to back
0.38.0 changes the calls between the oracle script and the gateway script: each user's Durable Object now reads Matrix media from the homeserver itself and asks the gateway only for the media event's small JSON. Deploy both scripts from the same release one after the other, the oracle script first. Until the gateway script is on 0.38.0, a file referenced by its Matrix event cannot be read (the model is told it could not be downloaded, and the turn runs), while a file referenced by its mxc:// URI is read as before. An older oracle script cannot read Matrix media from a 0.38.0 gateway at all.
Upgrade to 0.37.0 or later for long threads
Long threads, and above all turns with tool calls, need 0.37.0 or later. Up to 0.36.1, a turn held several copies of its thread: each tool call ran as its own graph task with a copy of the whole state, LangSmith traces of graph, node and middleware runs carried the whole thread, the checkpoint saver serialized every unchanged message again on each write, and a few literals above U+00FF made V8 store the whole minified source at two bytes per character (0.35.1 and earlier also opened a gzip stream per message row). 0.37.0 runs a message's tool calls in one tools node, traces the newest 4 messages of such runs (a model's prompt stays whole, and no run is dropped), re-keys unchanged rows without serializing them, and keeps its literals Latin-1. On a Companion-size oracle, 0.36.1 without minify reset tool turns at about 480,000 to 536,000 tokens. Measured with 0.37.0 on deployed devnet workers, with the same composition, a 1,050,000-token model, minify on and tracing on:
| Test | Result |
|---|---|
| Long thread, 369 turns in two sessions | 0 organic isolate resets |
| Tool-turn headroom, 90,000 to 560,000 tokens and after summarization | 72 to 77 MiB |
| A real summary that read 487,887 tokens | no reset |
| Up to 4,000 short messages in one session: 1,866 / 3,001 / 4,003 messages | at least 70 / 68 / at least 70 MiB spare (70 MiB is the probe's maximum), 0 organic resets |
| 1, 2, 3 or 5 parallel sessions of one user | at least 70 MiB spare each |
For comparison, 0.36.1 without minify left 31 MiB spare at 1,000 messages. The measurements are in qiforge #459 and its burst results.
Context limits are fractions of the model's own context window, with no separate memory cap: on a 1,050,000-token model, history is summarized at 630,000 tokens by default. Each turn logs them on its [context] line (see troubleshooting).
A run that keeps resetting the isolate is recovered at most 8 times in all (RUN_RECOVERY_TOTAL_ATTEMPTS), then ends interrupted and the user gets a failure notice instead of a long wait. See environment variables.