Build a full-stack application
This tutorial builds a real chat product with a React browser client, your own backend, the Iztro Agent SDK, and your own application database. You can use either Python/FastAPI or TypeScript/Express for the backend.
You do not need to build conversation memory, replay the entire message history on every turn, or expose an Iztro API key to the browser. You still own authentication, authorization, product data, and every side effect in your application.
Complete source code:
First understand the architecture
React browser
| Your JSON API + Server-Sent Events (no Iztro API key)
Your backend: FastAPI or Express
|-- Authentication and conversation ownership checks
|-- ChatSession ----------> Iztro Conversations (message context)
|-- Agent streaming ------> Iztro hosted model and chart tools
`-- Your database --------> users, plans, titles, saved reports, business data
The most important rule is:
ChatSessionowns conversational context. Your database owns business truth and authorization.
Who stores what?
| Data | Owner | Why |
|---|---|---|
| User and assistant message history | Iztro ChatSession | The Agent can resume context without your backend rebuilding every prompt. |
| Login, roles, subscription, quota | Your backend and database | These decide who may perform an action and are never inferred from chat text. |
| Orders, bookings, CRM records, saved reports | Your database | These are product records and may require transactions, audits, or deletion rules. |
| Conversation title, pin state, feedback, branch metadata | Usually your database | These are product UI features, not model context. |
| Birth profile | Your choice | Store a normalized profile with consent if the product needs reuse; otherwise keep it only in the conversation. |
| Iztro API key | Backend environment or secret manager | A browser must never receive the key. |
| Local tool execution | Your backend | Your code validates inputs, checks permissions, and controls side effects. |
conversation_id identifies a hosted conversation; it is not proof that the caller owns it. Every read, stream, rename, fork, and delete route must also check the signed-in user.
What you will build
The finished demo supports:
- creating, listing, resuming, renaming, forking, and deleting conversations;
- streaming answer text and Iztro chart-tool events to React;
- storing only product metadata locally instead of duplicating all chat messages;
- isolating conversations by
external_user_id; - keeping the API key entirely on the backend.
For a first integration, get “create → send → stream → resume” working before adding rename, fork, or message editing.
Step 1: run a complete demo
You need an API key from the Iztro Console, Node.js 20.19+ (or 22.12+), and Python 3.10+ for the Python version.
- Python / FastAPI
- TypeScript / Express
git clone https://github.com/SylarLong/openai-iztro-agents-python.git
cd openai-iztro-agents-python/examples/fullstack-demo/backend
python -m venv .venv
# macOS/Linux: source .venv/bin/activate
# Windows PowerShell: .venv\Scripts\Activate.ps1
pip install -r requirements.txt
cp .env.example .env
Set ZIWEI_API_KEY in backend/.env, then start FastAPI:
uvicorn app.main:app --reload --host 0.0.0.0 --port 8788
In a second terminal:
cd openai-iztro-agents-python/examples/fullstack-demo/frontend
npm install
npm run dev
Open http://localhost:5192.
git clone https://github.com/SylarLong/openai-iztro-agents-js.git
cd openai-iztro-agents-js
npm install
npm run build
cd examples/fullstack-demo/backend
npm install
cp .env.example .env
Set ZIWEI_API_KEY in backend/.env, then start Express:
npm run dev
In a second terminal:
cd openai-iztro-agents-js/examples/fullstack-demo/frontend
npm install
npm run dev
Open http://localhost:5193.
The sample uses a local editable/file dependency so it can exercise the SDK source in the cloned repository. In your own application, install the published package instead:
- Python / FastAPI
- TypeScript / Express
pip install openai-iztro-agents fastapi "uvicorn[standard]"
npm install openai-iztro-agents @openai/agents express
Step 2: authenticate the user on your backend
The demo has a “demo user” switch so you can see conversation isolation locally. Do not copy that trust model into production. A browser-supplied external_user_id can be changed by anyone.
Your production route should first verify a session cookie or bearer token, then derive the stable user ID from that identity:
- Python / FastAPI
- TypeScript / Express
@app.post("/api/conversations/{conversation_id}/messages/stream")
async def stream_message(
conversation_id: str,
request: StreamMessageRequest,
current_user: User = Depends(require_user),
):
user_id = str(current_user.id) # Never read this from request JSON.
await ensure_owned(conversation_id, user_id)
...
app.post('/api/conversations/:conversationId/messages/stream', requireUser, async (req, res) => {
const userId = String(req.user.id); // Set by verified auth middleware.
await ensureOwned(req.params.conversationId, userId);
// ...
});
Return 404 rather than exposing whether another user's conversation exists. Apply the same ownership check to list, read, rename, fork, edit, and delete operations.
Step 3: create the session and save only the mapping you need
Create ChatSession on the backend with the authenticated user ID. The hosted conversation is created lazily, and its ID is available as session_id in Python or sessionId in TypeScript.
- Python / FastAPI
- TypeScript / Express
from iztro_agents import ChatSession
session = ChatSession(
external_user_id=user_id,
api_key=os.environ["ZIWEI_API_KEY"],
)
await session.get_items() # Creates the conversation if needed.
conversation_id = session.session_id
import {ChatSession} from 'openai-iztro-agents';
const session = new ChatSession({
externalUserId: userId,
apiKey: process.env.ZIWEI_API_KEY!,
});
await session.getItems(); // Creates the conversation if needed.
const conversationId = session.sessionId;
A minimal application table can look like this:
create table agent_conversations (
id uuid primary key,
user_id uuid not null references users(id),
iztro_conversation_id text not null unique,
title text not null default 'New conversation',
parent_conversation_id text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index agent_conversations_owner
on agent_conversations(user_id, updated_at desc);
Use this table for ownership and UI metadata. Do not copy every message into it unless your product has a separate compliance or analytics requirement and your privacy policy covers that copy.
Step 4: stream one Agent turn from the backend
The backend resumes the hosted conversation, runs the Agent, and translates SDK events into a small browser-facing SSE contract.
- Python / FastAPI
- TypeScript / Express
import json
import os
from agents import Runner
from fastapi.responses import StreamingResponse
from openai.types.responses import ResponseTextDeltaEvent
from iztro_agents import ChatSession, IztroToolEvent, iztro_ziwei_agent
def sse(event: str, data: object) -> bytes:
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
return f"event: {event}\ndata: {payload}\n\n".encode()
async def events(conversation_id: str, user_id: str, message: str):
session = ChatSession(
conversation_id=conversation_id,
external_user_id=user_id,
api_key=os.environ["ZIWEI_API_KEY"],
)
try:
result = Runner.run_streamed(
iztro_ziwei_agent(api_key=os.environ["ZIWEI_API_KEY"]),
message,
session=session,
)
async for event in result.stream_events():
if event.type != "raw_response_event":
continue
if isinstance(event.data, IztroToolEvent):
yield sse("chart", {"tools": event.data.tools})
elif isinstance(event.data, ResponseTextDeltaEvent) and event.data.delta:
yield sse("delta", {"text": event.data.delta})
yield sse("done", {"conversation_id": session.session_id})
except Exception as error:
yield sse("error", {"message": "The response could not be completed."})
finally:
await session.close()
return StreamingResponse(
events(conversation_id, user_id, request.message),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
import type {Response} from 'express';
import {ChatSession, isIztroToolEvent, iztroZiweiAgent, run} from 'openai-iztro-agents';
function writeEvent(res: Response, event: string, data: unknown) {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
res.set({
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
const session = new ChatSession({
conversationId,
externalUserId: userId,
apiKey: process.env.ZIWEI_API_KEY!,
});
try {
const agent = iztroZiweiAgent({apiKey: process.env.ZIWEI_API_KEY!});
const streamed = await run(agent, message, {session, stream: true});
for await (const event of streamed) {
if (event.type !== 'raw_model_stream_event') continue;
const data = event.data as unknown;
if (isIztroToolEvent(data)) {
writeEvent(res, 'chart', {tools: data.tools});
} else if (event.data.type === 'output_text_delta') {
writeEvent(res, 'delta', {text: event.data.delta});
}
}
await streamed.completed;
writeEvent(res, 'done', {conversation_id: session.sessionId});
} catch {
writeEvent(res, 'error', {message: 'The response could not be completed.'});
} finally {
await session.close();
res.end();
}
The complete demos also send a conversation event when metadata changes. Keep the browser contract small and stable:
| Event | Payload | Browser action |
|---|---|---|
conversation | conversation summary | Update title, branch, and list metadata. |
chart | {tools: string[]} | Show which hosted Iztro chart calculation ran. |
delta | {text: string} | Append text to the pending assistant message. |
done | final identifiers/summary | Mark the answer complete and refresh metadata. |
error | safe public message | Stop the loading state and show retry UI. |
Log the full exception on the server, but do not stream stack traces, API keys, or provider response bodies to the browser.
Step 5: consume SSE in React
Because the chat request uses POST, use fetch() and read its response body rather than the browser's GET-only EventSource API.
const response = await fetch(`/api/conversations/${conversationId}/messages/stream`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message}),
});
if (!response.ok || !response.body) throw new Error('Stream request failed');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const {value, done} = await reader.read();
buffer += decoder.decode(value, {stream: !done}).replace(/\r\n/g, '\n');
const blocks = buffer.split('\n\n');
buffer = blocks.pop() ?? '';
for (const block of blocks) {
const event = block.match(/^event:\s*(.+)$/m)?.[1] ?? 'message';
const raw = block.match(/^data:\s*(.+)$/m)?.[1];
if (!raw) continue;
const data = JSON.parse(raw);
if (event === 'delta') appendAssistantText(data.text);
if (event === 'chart') showChartTools(data.tools);
if (event === 'done') finishAssistantMessage();
if (event === 'error') throw new Error(data.message);
}
if (done) break;
}
The full demos include robust parsing, optimistic messages, Markdown rendering, loading states, and error recovery. Use their App.tsx implementation for Python or App.tsx implementation for TypeScript as the production-shaped reference.
Step 6: add the conversation lifecycle
Build these routes around ChatSession:
| Method | Route | SDK action |
|---|---|---|
GET | /api/conversations | List conversations for the authenticated external_user_id. |
POST | /api/conversations | Create a ChatSession, then save its local metadata. |
GET | /api/conversations/:id | Check ownership, then call get_items / getItems. |
PATCH | /api/conversations/:id | Rename local UI metadata after checking ownership. |
DELETE | /api/conversations/:id | Call clear_session / clearSession, then delete local metadata. |
POST | /api/conversations/:id/fork | Fork the hosted context and create metadata for the new ID. |
POST | /api/conversations/:id/messages/stream | Run the next turn and stream SSE. |
The order on deletion matters: delete the hosted conversation first, then its local mapping. Otherwise a provider failure can leave an undiscoverable hosted conversation behind. See ChatSession management for the lifecycle methods and identifier boundaries.
Step 7: connect your own database through tools
If the Agent needs a subscription status, booking, or saved profile, expose a narrowly scoped backend tool. The tool should receive only validated inputs and should use the authenticated user from trusted server context—not a user ID chosen by the model.
Good tool output is minimal, structured, and free of secrets. For actions such as charging, booking, sending, or deleting, require confirmation or human approval before the side effect.
There is one current integration boundary to plan for: hosted Iztro tools work with streaming, while developer-defined/custom tool loops should use a non-streaming run. See non-streaming runs and tools. You can still stream status from your own backend, but do not assume the SDK can combine every custom tool loop with hosted streaming in one run.
Step 8: verify the complete flow
Use two users and test this sequence:
- User A creates a conversation and sends a message.
- Reload the browser and confirm the same conversation resumes with context.
- User B cannot read, stream to, rename, fork, or delete User A's conversation.
- A
chartevent appears only when an Iztro chart tool actually runs. - Deleting a conversation removes both hosted context and local metadata.
- Browser network responses and built JavaScript contain no API key.
Run the included checks before changing the demo:
- Python / FastAPI
- TypeScript / Express
pytest examples/fullstack-demo/backend/tests -q
cd examples/fullstack-demo/frontend
npm run build
cd examples/fullstack-demo/backend
npm test
npm run build
cd ../frontend
npm run build
Production checklist
- Keep
ZIWEI_API_KEYin a secret manager or backend environment; rotate it if it is ever exposed. - Derive
external_user_idfrom verified authentication and check ownership on every conversation route. - Use HTTPS, an explicit CORS allowlist, request-size limits, rate limits, and abuse controls.
- Disable reverse-proxy buffering for SSE and set an idle timeout long enough for model responses.
- Validate all tool inputs; use transactions and idempotency keys for side effects.
- Define retention and account-deletion behavior for both hosted conversations and your local records.
- Store consent and access rules for sensitive birth or profile data.
- Add request IDs and server-side error logs without logging secrets or unnecessary personal data.
You now have a clean boundary: React owns interaction, your backend owns trust and business operations, ChatSession owns conversation context, and your database owns durable product truth.