Tutorial: build a support agent

In this tutorial you build a small but complete support agent for an online shop. It runs in your terminal, and every part of it is what you would use in a real app:

  • Tools: the agent looks orders up and issues refunds.
  • Approval: a refund waits until a staff member says yes.
  • A durable thread: the conversation is saved in a SQLite file, so you can stop the program and continue later.
  • Memory: facts about a customer are kept across conversations.
  • Streaming: the answer appears as the model writes it.
  • Tests: the whole flow is tested without a model, a key or the network.

It takes about 30 minutes. Pick Python or TypeScript in any code tab: the whole page follows.

1. Create the project#

mkdir support-desk && cd support-desk
python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install criogenai pytest
mkdir support-desk && cd support-desk
npm init -y
npm pkg set type=module
npm install criogenai
npm install --save-dev tsx typescript @types/node

tsx runs TypeScript files directly; type=module lets the files use await at the top level.

Put your key in a file named .env in the folder, and keep that file out of version control:

# .env
OPENAI_API_KEY=sk-...
echo ".env" >> .gitignore

CriogenAI reads keys only from the environment. load_env() / loadEnv() (used in step 5) copies the .env file into the environment; it never prints a value and never replaces a variable that is already set.

2. Write the tools#

A tool is a function the model may call. Create support_agent.py (or support_agent.ts) and start with two tools:

from typing import Any, Dict

from criogenai import ToolError, tool

# A stand-in for your order database.
ORDERS: Dict[str, Dict[str, Any]] = {
    "A-1001": {"status": "delivered", "total": 48.0},
    "A-1002": {"status": "in transit", "total": 120.0},
}


@tool(read_only=True)
def get_order(order_id: str) -> dict:
    """Look up an order by its id (like A-1001): its status and total."""
    order = ORDERS.get(order_id)
    if order is None:
        # The model sees this message and can ask the customer again.
        raise ToolError(f"No order {order_id}. Ask the customer to check the id.")
    return {"order_id": order_id, **order}


@tool(destructive=True)
def issue_refund(order_id: str, amount: float, reason: str) -> dict:
    """Refund part or all of an order."""
    order = ORDERS.get(order_id)
    if order is None:
        raise ToolError(f"No order {order_id}")
    if not 0 < amount <= order["total"]:
        raise ToolError(f"A refund must be more than 0 and at most {order['total']}")
    return {"order_id": order_id, "refunded": amount, "reason": reason}
import { ToolError, tool } from "criogenai";

// A stand-in for your order database.
const ORDERS: Record<string, { status: string; total: number }> = {
  "A-1001": { status: "delivered", total: 48 },
  "A-1002": { status: "in transit", total: 120 },
};

export const getOrder = tool<{ orderId: string }>({
  name: "get_order",
  description: "Look up an order by its id (like A-1001): its status and total.",
  readOnly: true,
  input: {
    type: "object",
    properties: { orderId: { type: "string" } },
    required: ["orderId"],
  },
  run: ({ orderId }) => {
    const order = ORDERS[orderId];
    // The model sees this message and can ask the customer again.
    if (!order) throw new ToolError(`No order ${orderId}. Ask the customer to check the id.`);
    return { orderId, ...order };
  },
});

export const issueRefund = tool<{ orderId: string; amount: number; reason: string }>({
  name: "issue_refund",
  description: "Refund part or all of an order.",
  destructive: true,
  input: {
    type: "object",
    properties: {
      orderId: { type: "string" },
      amount: { type: "number", exclusiveMinimum: 0 },
      reason: { type: "string" },
    },
    required: ["orderId", "amount", "reason"],
  },
  run: ({ orderId, amount, reason }) => {
    const order = ORDERS[orderId];
    if (!order) throw new ToolError(`No order ${orderId}`);
    if (amount > order.total) throw new ToolError(`A refund must be at most ${order.total}`);
    return { orderId, refunded: amount, reason };
  },
});

What matters here:

  • The schema. In Python, the type hints (order_id: str, amount: float) become the JSON Schema the model sees, and the docstring becomes the description. In TypeScript you write the schema in input. The engine checks the model's arguments against it before your function runs: a missing amount or a string where a number belongs goes back to the model, never to your code.
  • ToolError is a failure the model should see. "No order Z-9" goes back as the tool's result, and the model can ask the customer again. Any other exception counts as a crash (see Tools).
  • read_only / destructive describe the tool. read_only tools may be retried after a transient failure; destructive ones never are.

Write tool descriptions for the model: say what the tool does and what the arguments look like ("an id like A-1001"). Models choose tools by their descriptions.

3. Build the agent#

Add the agent to the same file. It is a function so the tests in step 9 can build the same agent with a fake model:

from criogenai import Agent, Memory, Storage

INSTRUCTIONS = """You are the support assistant of an online shop.
Look an order up before you answer a question about it.
To refund, call issue_refund: a staff member approves it before it runs.
Save lasting facts about the customer (preferences, past problems) with remember."""


def build_agent(model: Any, storage: Storage) -> Agent:
    return Agent(
        model,
        instructions=INSTRUCTIONS,
        tools=[get_order, issue_refund],
        approve=["issue_refund"],  # pause before every refund
        storage=storage,  # conversations survive restarts
        memory=Memory.by_state("customer_id", "customers"),  # facts per customer
        limits={"model_calls": 12, "tool_calls": 20},
    )
import { Agent, Memory, Storage, type Model } from "criogenai";

const INSTRUCTIONS = `You are the support assistant of an online shop.
Look an order up before you answer a question about it.
To refund, call issue_refund: a staff member approves it before it runs.
Save lasting facts about the customer (preferences, past problems) with remember.`;

export function buildAgent(model: Model, storage: Storage): Agent {
  return new Agent({
    model,
    instructions: INSTRUCTIONS,
    tools: [getOrder, issueRefund],
    approve: ["issue_refund"], // pause before every refund
    storage, // conversations survive restarts
    memory: Memory.byState("customer_id", "customers"), // facts per customer
    limits: { modelCalls: 12, toolCalls: 20 },
  });
}

Each option, in order:

OptionWhat it does
instructionsThe system prompt: who the agent is and the rules it follows
toolsWhat the model may call
approveTools that wait for a person. When the model calls issue_refund, the run pauses before any tool of that turn runs
storageWhere threads, memory and approvals are saved. A SQLite file here; Postgres when several processes share it
memoryLong-term memory, one namespace per customer: customers/<customer_id>
limitsStops a run that loops: at most 12 model calls and 20 tool calls (the defaults are 25 and 200)

4. Choose the model#

The model is a string "provider:model". Step 5 uses "openai:gpt-5-mini", which reads OPENAI_API_KEY. Others work the same way, each with its own key variable:

Model stringKey
"anthropic:claude-haiku-5-5"ANTHROPIC_API_KEY
"gemini:gemini-2.5-flash"GEMINI_API_KEY
"openrouter:meta-llama/llama-3.3-70b-instruct"OPENROUTER_API_KEY
"ollama:llama3.2"none: Ollama on this computer

Building an agent with a hosted model and no key fails at once with a message naming the variable to set. The full list is in Models and providers.

5. Talk to it in the terminal#

Create support_chat.py (or support_chat.ts):

import asyncio
import sys

from criogenai import Reply, Storage, Thread, load_env
from support_agent import build_agent

load_env()  # OPENAI_API_KEY from .env, if there is one


async def settle_approvals(desk: Thread, reply: Reply) -> Reply:
    """Asks a staff member about every refund the agent wants to make."""
    while reply.needs_approval:
        for request in reply.approvals:
            print(f"  [staff] {request.tool} {request.args}")
        if input("  [staff] approve? y/n > ").strip().lower() == "y":
            reply = await desk.approve()
        else:
            reply = await desk.reject("A staff member declined the refund.")
    return reply


async def main(customer_id: str, ticket_id: str) -> None:
    storage = Storage("sqlite://support.db")
    agent = build_agent("openai:gpt-5-mini", storage)
    chat = agent.thread(ticket_id, actor=f"customer:{customer_id}")
    desk = agent.thread(ticket_id, actor="staff:sam")  # same thread, another person
    try:
        while True:
            text = input("you > ").strip()
            if text in ("", "quit"):
                break
            reply = await chat.send(text, customer_id=customer_id)
            reply = await settle_approvals(desk, reply)
            print("agent >", reply.text)
    finally:
        await storage.close()


if __name__ == "__main__":
    # python support_chat.py <customer id> <ticket id>
    asyncio.run(main(*(sys.argv[1:3] if len(sys.argv) >= 3 else ["c-7", "ticket-1"])))
import * as readline from "node:readline/promises";
import { Storage, loadEnv, type Reply, type Thread } from "criogenai";
import { buildAgent } from "./support_agent.js";

loadEnv(); // OPENAI_API_KEY from .env, if there is one

const terminal = readline.createInterface({ input: process.stdin, output: process.stdout });

/** Asks a staff member about every refund the agent wants to make. */
async function settleApprovals(desk: Thread, reply: Reply): Promise<Reply> {
  while (reply.needsApproval) {
    for (const request of reply.approvals) console.log(`  [staff] ${request.tool}`, request.args);
    const answer = await terminal.question("  [staff] approve? y/n > ");
    reply = answer.trim().toLowerCase() === "y"
      ? await desk.approve()
      : await desk.reject("A staff member declined the refund.");
  }
  return reply;
}

export async function main(customerId: string, ticketId: string): Promise<void> {
  const storage = new Storage("sqlite://support.db");
  const agent = buildAgent("openai:gpt-5-mini", storage);
  const chat = agent.thread(ticketId, { actor: `customer:${customerId}` });
  const desk = agent.thread(ticketId, { actor: "staff:sam" }); // same thread, another person
  try {
    for (;;) {
      const text = (await terminal.question("you > ")).trim();
      if (text === "" || text === "quit") break;
      let reply = await chat.send(text, { state: { customer_id: customerId } });
      reply = await settleApprovals(desk, reply);
      console.log("agent >", reply.text);
    }
  } finally {
    terminal.close();
    await storage.close();
  }
}

// npx tsx support_chat.ts <customer id> <ticket id>
await main(process.argv[2] ?? "c-7", process.argv[3] ?? "ticket-1");

Run it:

python support_chat.py c-7 ticket-1
npx tsx support_chat.ts c-7 ticket-1
you > Where is my order A-1002?
agent > Your order A-1002 is in transit.
you > A-1001 arrived broken. Can I get 20 back?
  [staff] issue_refund {'order_id': 'A-1001', 'amount': 20, 'reason': 'arrived broken'}
  [staff] approve? y/n > y
agent > Done: I refunded 20 for order A-1001.

(The model's words will differ.) What happened:

  1. agent.thread("ticket-1") is a thread: one conversation, stored in support.db. Each send runs one turn on top of everything said before.
  2. The model called issue_refund. The run paused and send returned a reply with needs_approval (needsApproval) and the waiting calls in approvals. The pause is saved in the database.
  3. desk.approve() continued the same thread. desk is a second handle on the same thread with another actor, so the audit trail (if you turn it on) records that staff:sam approved, not the customer.
  4. reject(reason) would skip the refund; the model sees the reason and tells the customer.

While an approval waits, send refuses with thread_suspended: answer the approval first.

6. Stop it and come back#

Type quit, then start the program again with the same ticket id and ask "What did we just do?". The agent remembers: the conversation was saved after every step. Use a new ticket id for a new conversation.

The same holds after a crash. If the process dies in the middle of a turn, the thread keeps the unfinished run; send then fails with thread_interrupted, and retry() continues the run where it stopped. Tool calls that had finished are not run again, so a refund is never issued twice. See Conversations (threads).

7. Memory per customer#

Memory.by_state("customer_id", "customers") gives each customer a namespace, customers/c-7, chosen by the customer_id you pass with each message. Before every model call, the facts that best match the question are added to the request. The model also gets three tools: remember, forget and search_memory.

Try it: in ticket-1, say "Please always contact me by email". Then start a new ticket for the same customer:

python support_chat.py c-7 ticket-2     # or: npx tsx support_chat.ts c-7 ticket-2

and ask "How do I prefer to be contacted?". The model decides what to save (the instructions ask it to); when it called remember, the fact was saved under customers/c-7, not under the ticket, so every ticket of that customer sees it. A run without customer_id fails (invalid_state) instead of reading another customer's facts. Your own code can read, write and delete memory too; see Long-term memory.

8. Stream the answer#

send returns when the turn is over. To print the answer while the model writes it, use stream, which yields events and then gives the same saved reply:

async def send_streaming(chat: Thread, text: str, customer_id: str) -> Reply:
    """send(), printing the answer as it is written."""
    stream = chat.stream(text, customer_id=customer_id)
    print("agent > ", end="", flush=True)
    async for event in stream:
        if event.type == "text":
            print(event.text, end="", flush=True)
        elif event.type == "tool_call_started":
            print(f"[{event.tool}] ", end="", flush=True)
    print()
    return await stream.reply()  # the same saved reply send() returns
/** send(), printing the answer as it is written. */
export async function sendStreaming(chat: Thread, text: string, customerId: string): Promise<Reply> {
  const stream = chat.stream(text, { state: { customer_id: customerId } });
  process.stdout.write("agent > ");
  for await (const event of stream) {
    if (event.type === "text") process.stdout.write(event.text);
    else if (event.type === "tool_call_started") process.stdout.write(`[${event.tool}] `);
  }
  process.stdout.write("\n");
  return stream.reply(); // the same saved reply send() returns
}

In the loop of step 5, replace the send call with send_streaming(chat, text, customer_id) (sendStreaming(chat, text, customerId)). The text after an approval comes from approve(); use stream_approve() (streamApprove()) to stream that too. Leaving the loop early cancels the run; it is still saved, and retry() continues it. See Streaming.

9. Test it without a model#

FakeModel plays a script: each entry is the next model reply, either text or a tool call. Nothing goes over the network, so tests are fast, free and the same every time. Create support_test.py (or support_test.ts):

import asyncio

from criogenai import FakeModel, FakeReply, Storage
from support_agent import build_agent


def test_a_refund_waits_for_a_person() -> None:
    async def scenario() -> None:
        model = FakeModel([
            FakeReply.tool_call("issue_refund", {"order_id": "A-1001", "amount": 20, "reason": "broken"}),
            "I have refunded 20.",
        ])
        agent = build_agent(model, Storage())  # in memory: nothing left behind
        chat = agent.thread("t-1")

        reply = await chat.send("Please refund 20 on A-1001", customer_id="c-7")
        assert reply.needs_approval
        assert reply.approvals[0].args["amount"] == 20

        reply = await chat.approve()
        assert reply.text == "I have refunded 20."
        assert reply.tool_calls == 1

    asyncio.run(scenario())


def test_an_unknown_order_goes_back_to_the_model() -> None:
    model = FakeModel([FakeReply.tool_call("get_order", {"order_id": "Z-9"}), "Which order?"])
    agent = build_agent(model, Storage())
    result = agent.run_sync("Where is Z-9?", customer_id="c-7")
    tool_message = result.messages[2]
    assert tool_message["status"] == "error"
    assert "No order Z-9" in tool_message["content"][0]["text"]
    assert result.text == "Which order?"
import assert from "node:assert/strict";
import { test } from "node:test";
import { FakeModel, FakeReply, Storage } from "criogenai";
import { buildAgent } from "./support_agent.js";

test("a refund waits for a person", async () => {
  const model = new FakeModel([
    FakeReply.toolCall("issue_refund", { orderId: "A-1001", amount: 20, reason: "broken" }),
    "I have refunded 20.",
  ]);
  const agent = buildAgent(model, new Storage()); // in memory: nothing left behind
  const chat = agent.thread("t-1");

  let reply = await chat.send("Please refund 20 on A-1001", { state: { customer_id: "c-7" } });
  assert.ok(reply.needsApproval);
  assert.equal(reply.approvals[0].args.amount, 20);

  reply = await chat.approve();
  assert.equal(reply.text, "I have refunded 20.");
  assert.equal(reply.toolCalls, 1);
});

test("an unknown order goes back to the model", async () => {
  const model = new FakeModel([FakeReply.toolCall("get_order", { orderId: "Z-9" }), "Which order?"]);
  const agent = buildAgent(model, new Storage());
  const result = await agent.run("Where is Z-9?", { state: { customer_id: "c-7" } });
  const toolMessage = result.messages[2] as { status: string; content: { text: string }[] };
  assert.equal(toolMessage.status, "error");
  assert.match(toolMessage.content[0].text, /No order Z-9/);
  assert.equal(result.text, "Which order?");
});

Run the tests:

pytest support_test.py
npx tsx --test support_test.ts

The first test checks the approval flow end to end; the second checks that a ToolError reaches the model as an error result. The engine, the tools, the storage and the memory are all real; only the model is scripted. More in Testing without a model.

What you built#

PieceWhere it lives
Tools with checked argumentssupport_agent: get_order, issue_refund
An agent with approval, storage, memory and limitssupport_agent: build_agent / buildAgent
A durable conversation per ticketsupport.db (SQLite)
A staff decision that waits in the databaseapprove() / reject() on any handle of the thread
Tests with a scripted modelsupport_test

Next#