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
Storageand 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_idfor memory, theactorfor 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
asyncmethods (await chat.send(...)) in async routes. The*_syncmethods 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#
| Deployment | Storage |
|---|---|
| One process on one machine (a VM, a container with one worker) | SQLite on a persistent disk |
| Several workers or replicas | Postgres |
| Serverless functions | Postgres (the local disk is not kept, and instances run in parallel) |
| Tests and previews | In 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.envfile into the environment for local development; it never prints a value and never replaces a variable that is already set. Keep.envout 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#
| Platform | Works |
|---|---|
| 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) images | not 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
limitsand 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;
.envonly on developer machines. - User identity, thread ids and actors from your authentication.
- Error codes, not messages, in responses.
-
limitsand timeouts below the platform's request limit. - A
/healthroute withready(). - For sensitive data:
redact, encryption, audit and retention.
Next#
- Using CriogenAI in your app: health, typed output, cost, redaction, tracing.
- Storage and Encryption, audit, retention.
- Troubleshooting and FAQ.