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 pytestmkdir support-desk && cd support-desk
npm init -y
npm pkg set type=module
npm install criogenai
npm install --save-dev tsx typescript @types/nodetsx 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" >> .gitignoreCriogenAI 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 ininput. The engine checks the model's arguments against it before your function runs: a missingamountor a string where a number belongs goes back to the model, never to your code. ToolErroris 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/destructivedescribe the tool.read_onlytools may be retried after a transient failure;destructiveones 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:
| Option | What it does |
|---|---|
instructions | The system prompt: who the agent is and the rules it follows |
tools | What the model may call |
approve | Tools that wait for a person. When the model calls issue_refund, the run pauses before any tool of that turn runs |
storage | Where threads, memory and approvals are saved. A SQLite file here; Postgres when several processes share it |
memory | Long-term memory, one namespace per customer: customers/<customer_id> |
limits | Stops 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 string | Key |
|---|---|
"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-1npx tsx support_chat.ts c-7 ticket-1you > 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:
agent.thread("ticket-1")is a thread: one conversation, stored insupport.db. Eachsendruns one turn on top of everything said before.- The model called
issue_refund. The run paused andsendreturned a reply withneeds_approval(needsApproval) and the waiting calls inapprovals. The pause is saved in the database. desk.approve()continued the same thread.deskis a second handle on the same thread with anotheractor, so the audit trail (if you turn it on) records thatstaff:samapproved, not the customer.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-2and 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.pynpx tsx --test support_test.tsThe 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#
| Piece | Where it lives |
|---|---|
| Tools with checked arguments | support_agent: get_order, issue_refund |
| An agent with approval, storage, memory and limits | support_agent: build_agent / buildAgent |
| A durable conversation per ticket | support.db (SQLite) |
| A staff decision that waits in the database | approve() / reject() on any handle of the thread |
| Tests with a scripted model | support_test |