Deploying

CriogenAI runs inside your server process: there is no CriogenAI service to deploy. Your web app imports the package, builds its agents once, and calls them from request handlers; the browser talks to your server, never to the engine or a model provider. This page shows that setup with FastAPI and Next.js, then covers storage, keys, serverless and the gateway. For health checks, redaction, cost and tracing, see Using CriogenAI in your app.

The shape of a deployment#

browser ──HTTP──► your server (FastAPI, Next.js, Express, ...)
                    │  one Storage and one Agent per process
                    ├──► model provider (OpenAI, Anthropic, Ollama on your network, ...)
                    └──► database (SQLite file, or Postgres for several processes)

Rules that hold everywhere:

  • Build once, use per request. Create the Storage and the agents at start-up (module level, or a cached function). They are safe to use from many requests at once. Make a thread handle per request; it holds nothing but the id.
  • Who the user is comes from your authentication, not from the request body: the thread id they may open, the user_id for memory, the actor for the audit trail.
  • Return error codes, not messages. A message can quote what a tool or the model saw.
  • Close the storage at shutdown (await storage.close()).

FastAPI (Python)#

The tutorial's agent behind three routes: send a message, stream a message, approve a refund. customer_of and staff_of stand for your own authentication.

import json
import os
from contextlib import asynccontextmanager
from typing import AsyncIterator

from fastapi import Depends, FastAPI
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel

from criogenai import CriogenError, Storage
from support_agent import build_agent

# One Storage and one Agent per process, shared by every request.
storage = Storage(os.environ.get("DATABASE_URL", "sqlite://support.db"))
agent = build_agent(os.environ.get("MODEL", "openai:gpt-5-mini"), storage)


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    yield
    await storage.close()  # release the database at shutdown


app = FastAPI(lifespan=lifespan)


class Message(BaseModel):
    text: str


@app.post("/tickets/{ticket_id}/messages")
async def send(ticket_id: str, body: Message, customer_id: str = Depends(customer_of)) -> JSONResponse:
    chat = agent.thread(ticket_id, actor=f"customer:{customer_id}")
    try:
        reply = await chat.send(body.text, customer_id=customer_id)
    except CriogenError as error:
        status = 409 if error.code in ("thread_busy", "thread_suspended") else 502
        return JSONResponse({"error": error.code}, status_code=status)  # the code, never the message
    return JSONResponse({"text": reply.text, "needs_approval": reply.needs_approval})


@app.post("/tickets/{ticket_id}/stream")
async def stream(ticket_id: str, body: Message, customer_id: str = Depends(customer_of)) -> StreamingResponse:
    chat = agent.thread(ticket_id, actor=f"customer:{customer_id}")

    async def events() -> AsyncIterator[str]:
        # If the client disconnects, this generator is closed, which cancels the
        # run: it is saved, and retry() continues it.
        run = chat.stream(body.text, customer_id=customer_id)
        try:
            async for event in run:
                if event.type == "text":
                    yield f"event: text\ndata: {json.dumps({'text': event.text})}\n\n"
                elif event.type == "tool_call_started":
                    yield f"event: tool\ndata: {json.dumps({'tool': event.tool})}\n\n"
            reply = await run.reply()
            yield f"event: done\ndata: {json.dumps({'needs_approval': reply.needs_approval})}\n\n"
        except CriogenError as error:
            yield f"event: error\ndata: {json.dumps({'code': error.code})}\n\n"

    return StreamingResponse(events(), media_type="text/event-stream")


@app.post("/tickets/{ticket_id}/approve")
async def approve(ticket_id: str, staff_id: str = Depends(staff_of)) -> JSONResponse:
    reply = await agent.thread(ticket_id, actor=f"staff:{staff_id}").approve()
    return JSONResponse({"text": reply.text})
pip install criogenai fastapi uvicorn
DATABASE_URL=postgres://app:secret@db.internal/agents uvicorn deploy:app --workers 4
  • Use the async methods (await chat.send(...)) in async routes. The *_sync methods refuse to run inside an event loop.
  • Several workers are several processes: use Postgres, so they share threads and one-run-per-thread holds across them.

Next.js (TypeScript)#

One module builds the agent; route handlers use it.

// lib/agent.ts
import { Storage, type Agent } from "criogenai";
import { buildAgent } from "@/lib/support_agent";

// One Storage and one Agent per process, made on first use and shared by every
// request. (Next.js reloads modules in development: keep them on globalThis.)
const cache = globalThis as typeof globalThis & { supportAgent?: Agent };

export function supportAgent(): Agent {
  cache.supportAgent ??= buildAgent(
    process.env.MODEL ?? "openai:gpt-5-mini", // the key comes from OPENAI_API_KEY
    new Storage(process.env.DATABASE_URL ?? "sqlite://support.db"),
  );
  return cache.supportAgent;
}
// app/api/tickets/[id]/messages/route.ts
import { CriogenError } from "criogenai";

export const runtime = "nodejs"; // the engine is native code: not the Edge runtime

export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const { text } = (await request.json()) as { text: string };
  const customerId = await customerOf(request); // from your session, never from the body
  try {
    const reply = await supportAgent()
      .thread(id, { actor: `customer:${customerId}` })
      .send(text, { state: { customer_id: customerId }, signal: request.signal });
    return Response.json({ text: reply.text, needsApproval: reply.needsApproval });
  } catch (error) {
    if (error instanceof CriogenError) {
      const status = error.code === "thread_busy" || error.code === "thread_suspended" ? 409 : 502;
      return Response.json({ error: error.code }, { status }); // the code, never the message
    }
    throw error;
  }
}

The engine is native code, so the route runs on the Node.js runtime, not Edge, and the package stays out of the bundle:

// next.config.mjs
export default { serverExternalPackages: ["criogenai"] };

request.signal cancels the run when the client goes away; on a thread the run is saved and retry() continues it. Leave the signal out if a turn should finish even when the user closes the page.

Express, Fastify, Hono on Node.js and NestJS work the same way: build the agent once, call it in the handler, map CriogenError.code to a status.

Stream to a browser#

Forward stream events as Server-Sent Events. The browser reads them with fetch (or EventSource for GET routes) and needs no CriogenAI package.

The /stream route in the FastAPI example above does this: Starlette closes the generator when the client disconnects, which cancels the run.

// app/api/tickets/[id]/stream/route.ts
import { CriogenError } from "criogenai";

export const runtime = "nodejs";

export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const { text } = (await request.json()) as { text: string };
  const customerId = await customerOf(request);
  const stream = supportAgent()
    .thread(id, { actor: `customer:${customerId}` })
    .stream(text, { state: { customer_id: customerId } });

  const encoder = new TextEncoder();
  const body = new ReadableStream({
    async start(controller) {
      const send = (event: string, data: unknown) =>
        controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
      try {
        for await (const event of stream) {
          if (event.type === "text") send("text", { text: event.text });
          else if (event.type === "tool_call_started") send("tool", { tool: event.tool });
        }
        const reply = await stream.reply();
        send("done", { needsApproval: reply.needsApproval });
      } catch (error) {
        send("error", { code: error instanceof CriogenError ? error.code : "internal" });
      } finally {
        controller.close();
      }
    },
    cancel() {
      stream.cancel(); // the browser went away: stop the run (it is saved; retry continues it)
    },
  });
  return new Response(body, {
    headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
  });
}

Send only what the page shows (text, tool names, done, error codes). other events are engine diagnostics (a name, a node and a step, no content); the page rarely needs them.

Storage for your deployment#

DeploymentStorage
One process on one machine (a VM, a container with one worker)SQLite on a persistent disk
Several workers or replicasPostgres
Serverless functionsPostgres (the local disk is not kept, and instances run in parallel)
Tests and previewsIn memory

With Postgres, use a direct connection or PgBouncer in session mode, and TLS for a database on another machine. More in Storage.

Keys and settings#

  • Models read their keys from environment variables (OPENAI_API_KEY, ANTHROPIC_API_KEY, ...). Set them in your platform's secret settings.
  • load_env() / loadEnv() reads a .env file into the environment for local development; it never prints a value and never replaces a variable that is already set. Keep .env out of version control.
  • Building an agent with a hosted model and no key fails at start-up with the name of the variable to set, not on the first request.
  • Self-hosted models (Ollama, vLLM, LM Studio) need no key. A key is never sent over plain http:// to another machine unless you allow it explicitly.

Health checks#

ready() checks that the engine loads, and with a model, that the model answers (one tiny call). It never throws, so it fits a /health route:

report = await criogenai.ready("openai:gpt-5-mini")   # {"ok": ..., "engine": {...}, "model": {...}}
const report = await ready({ model: "openai:gpt-5-mini" });

Without a model it only checks the engine, which costs nothing: use that for frequent liveness probes, and the model check less often.

Serverless#

PlatformWorks
Functions on Node.js 18+ or Python 3.9+, Linux x64 or ARM64 with glibc 2.28 or newer (the Node.js runtimes of AWS Lambda and Vercel, for example)yes
Edge runtimes (Vercel Edge, Cloudflare Workers)not with the native package; criogenai/web runs the engine as WebAssembly but is not yet tested there (In the browser)
Alpine Linux (musl) imagesnot yet: use a Debian or Ubuntu based image

On serverless:

  • Use Postgres: a function's disk is not kept between invocations.
  • A function may be frozen as soon as it returns a response. Finish the turn (or the stream) before returning, rather than continuing in the background.
  • Approvals fit well: a run pauses, the function returns, and a later invocation (a webhook, a staff action) calls approve() on the same thread.
  • Function time limits apply to the whole turn, model calls and tools included. Set limits and tool timeouts below your platform's limit.

The LLM gateway#

criogenai-gateway is a separate server that speaks the OpenAI and Anthropic APIs in front of any provider, with virtual keys, rate limits, token budgets and usage records (The LLM gateway). It is part of the Rust crates, which are published when CriogenAI's code opens; from Python and TypeScript today, the Gateway class gives the same routing, retries and fallbacks inside your process (Models: call a model directly).

Checklist#

  • Agents and storage built once per process; close() at shutdown.
  • Postgres if more than one process or any serverless function.
  • Keys in the platform's secrets; .env only on developer machines.
  • User identity, thread ids and actors from your authentication.
  • Error codes, not messages, in responses.
  • limits and timeouts below the platform's request limit.
  • A /health route with ready().
  • For sensitive data: redact, encryption, audit and retention.

Next#