Which data belongs in your product database and which belongs to Dendrux, a minimal ChatGPT-style schema, the request lifecycle for one chat turn, and how to link each turn to its run.
Your app DB vs the Dendrux DB
If you are building a real product on top of Dendrux (a chatbot, an assistant, a workflow tool), the first question is almost always: where does my data go? You already have your own tables for users and conversations. Dendrux also writes tables. Knowing which side owns what is the difference between a clean integration and a painful one.
The short answer: Dendrux owns run state and the audit trail. You own everything your product shows to a user. They are two separate databases, linked by one ID.
The two databases
Your tables hold product truth: who the user is, which conversations exist, what each message says, what the UI renders. Dendrux's six tables hold execution truth: every LLM call, every tool invocation, the ordered event log, and the pause state needed to resume. The two meet at a single point: the run ID that your turn records (a column on the message or a row in a link table), plus the metadata.thread_id you stamp on every run.
The boundary rule. Do not use Dendrux tables as your product chat database. Store product-visible chats and messages in your own tables. Link to Dendrux using run IDs and run metadata. Treat the Dendrux schema as an internal, append-only audit surface that you read through
RunStore, never write to directly.
A minimal ChatGPT-style schema
There are two ways to link your product data to Dendrux, and which you pick is a real design decision. Both keep the boundary intact (your tables own product truth, Dendrux owns execution truth); they differ only in how the link is modeled. Every chat app starts with the same two tables:
CREATE TABLE chats (
id TEXT PRIMARY KEY, -- your ID format (ULID/UUID)
user_id TEXT NOT NULL,
title TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
chat_id TEXT NOT NULL REFERENCES chats(id),
role TEXT NOT NULL, -- 'user' | 'assistant'
content TEXT, -- nullable while an assistant turn streams
created_at TIMESTAMPTZ DEFAULT now()
);Pattern A: a run-ID column on messages (simplest)
Add dendrux_run_id (and a status) to the assistant message. That one column is the entire link back to Dendrux.
ALTER TABLE messages
ADD COLUMN status TEXT NOT NULL DEFAULT 'complete', -- 'pending' | 'streaming' | 'complete' | 'error'
ADD COLUMN dendrux_run_id TEXT; -- set on assistant messages; links to agent_runs.idThis is the right call for a prototype or a linear chat where one assistant message is produced by exactly one run and there is no regenerate. One lookup, no joins.
Pattern B: an agent_run_links turn table (production)
Model the turn as its own entity: one row ties together a user message, the assistant message it produced, and the run that connected them.
CREATE TABLE agent_run_links (
id TEXT PRIMARY KEY,
chat_id TEXT NOT NULL REFERENCES chats(id),
user_message_id TEXT NOT NULL REFERENCES messages(id),
assistant_message_id TEXT REFERENCES messages(id), -- null while pending; set once the answer row exists
dendrux_run_id TEXT UNIQUE, -- null until the run starts; unique so a run links to one turn
status TEXT NOT NULL DEFAULT 'pending', -- the TURN lifecycle lives here, not on the message
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_agent_run_links_chat ON agent_run_links(chat_id);Why this is the better shape for a real product:
- Regenerate falls out for free. Keep one user message, produce a new assistant message from a new run, and you simply get a second link row sharing
user_message_id. The invariants "one user message maps to one run" and "one assistant message is produced by one run" both still hold per row. Edit-and-rerun and retries behave the same way: a new row, the old one preserved, with a clean per-turn audit. - Status lives in the right place. The turn lifecycle (
pendingtostreamingtocompleteorerror) is a property of the linkage, not of the message content. Keeping it on the link row lets yourmessagesrows stay as pure content. - Your
messagestable carries zero Dendrux columns. All coupling to Dendrux is isolated in this one table, which is exactly the boundary this page is about.
Two modeling notes. Pick a single owner for turn status (the link row, not also the message) so they cannot drift. And decide whether assistant_message_id is nullable: if you create the assistant placeholder up front, as the lifecycle below does, it can be NOT NULL; if you create the link while still pending before any answer row exists, keep it nullable. dendrux_run_id stays null until the run starts and is UNIQUE so a run can never be double-linked; set it the moment you have it, which is immediately from stream().run_id, before the first token.
Which to use
- Prototype or linear chat, one answer per turn leans to Pattern A: fewer tables, fewer joins.
- A real product with regenerate, edit, retries, per-turn analytics, or strict message immutability leans to Pattern B. It is a fine default for that tier.
What not to store
The mistakes below all come from blurring the boundary. Avoid them:
- Do not duplicate
tool_callsinto yourmessagestable. Tool invocations, their params, and results already live in Dendrux. If your UI needs to show "called the refund tool," read it fromRunStore.get_tool_invocations(run_id), do not re-persist it. - Do not store raw provider payloads in your app DB unless you have a specific product reason. The full Anthropic/OpenAI request and response are already preserved verbatim in
llm_interactions. Copying them into your tables just duplicates a large audit record you do not own. - Do not query
react_tracesas your user-facing transcript. That table is the message history Dendrux ships to the model, in model-shaped form (tool roles, placeholder PII, internal ordering). Your transcript is your ownmessagesrows, which you control and can render exactly as the user should see them. - Do not write to any Dendrux table. It is append-only and owned by the runtime. Read through
RunStoreor the read router.
Dendrux does not reconstruct your conversation
This is the single most important thing to internalize, because it is easy to assume the opposite.
Dendrux does not automatically rebuild your next chat turn from previous runs. Your app loads the prior turns from your
messagestable and passes them in viahistory=.
Each agent.run() is one turn. Dendrux reads the history= you give it as input and does not persist it back as your conversation (it only records the new turn's execution into its own tables). So the loop is always: load prior turns from your DB, convert to ChatMessage, run, then save the new user message and the assistant answer back to your DB. See Chatbot threads for the history= and ChatMessage mechanics.
The request lifecycle for one turn
Here is the full path of a single chat message in a streaming app. The run ID is available immediately from the stream (before the first token), so you can link the assistant message at creation time and fill its content as the stream completes.
@app.post("/chats/{chat_id}/messages")
async def post_message(chat_id: str, body: NewMessage, user: User = Depends(auth)):
# 1. Persist the user's message in YOUR db.
await db.save_message(chat_id, role="user", content=body.text, status="complete")
# 2. Load prior turns from YOUR db and convert to Dendrux's input type.
rows = await db.load_messages(chat_id)
history = [
ChatMessage.user(m.content) if m.role == "user" else ChatMessage.assistant(m.content)
for m in rows
]
# 3. Start the run. run_id is available before iteration.
stream = agent.stream(
user_input=body.text,
history=history,
metadata={"thread_id": chat_id, "user_id": user.id},
)
# 4. Create the assistant placeholder linked to the run up front.
assistant_id = await db.save_message(
chat_id, role="assistant", content=None,
status="streaming", dendrux_run_id=stream.run_id,
)
# 5. Stream tokens to the client, accumulating the final answer.
async def emit():
answer = []
async with stream:
async for event in stream:
if event.type == RunEventType.TEXT_DELTA:
answer.append(event.text)
yield sse(event.text)
# 6. Persist the final answer back to YOUR db.
await db.update_message(assistant_id, content="".join(answer), status="complete")
return StreamingResponse(emit(), media_type="text/event-stream")The non-streaming version is the same shape with await agent.run(...) in place of the stream, saving result.answer and result.run_id after it returns.
The example above uses Pattern A (the run ID and status on the assistant message). With Pattern B, the steps are identical except the run ID and status live on a turn row instead: at step 4 insert an agent_run_links row (user_message_id, the freshly created assistant_message_id, dendrux_run_id=stream.run_id, status='streaming'), and at step 6 update that link row's status to complete while writing the answer to the plain messages row. Same choreography, one extra row write, and your messages table stays free of Dendrux columns.
Recommended metadata keys
metadata is opaque JSON that lands on the run row. Dendrux stores it and never reads it during execution; it exists purely for your dev-side queries and rollups. For a production app, stamp every run with:
metadata={
"thread_id": str(chat_id), # the conversation; the canonical chatbot key
"user_id": str(user_id), # who asked
"tenant_id": str(tenant_id), # org/workspace scoping for multi-tenant apps
"request_id": request_id, # ties a run to your HTTP/trace logs
}Keys are restricted to [A-Za-z0-9_-]+ for filter safety. With these in place you can answer "every run in this chat," "this user's cost this month," or "the run behind request X" without touching your own tables. The query mechanics are in Chatbot threads.
Read through the public APIs, not raw SQL
When you need Dendrux's execution data (for a cost view, a debug panel, a per-thread inspector), reach for the public read surface rather than writing SQL against the tables:
RunStorefor programmatic reads:list_runs(metadata_filter=...),get_events,get_llm_calls,get_tool_invocations,get_traces,get_pauses.make_read_routerto expose those same reads over HTTP and SSE for a frontend.
Direct table queries are reasonable only for advanced internal debugging. For anything your product depends on, use the APIs: they are the stable contract, the schema is not.
Where this fits
- Architecture: State persistence explains what each of Dendrux's six tables holds.
- Recipes: Chatbot threads for
history=,ChatMessage, andmetadata_filterqueries. - Recipes: Mount the read router to serve run data to your frontend.