Build Conversations
Create multi-turn chat experiences with a persistent ContextEngine store
The chat() helper from @deepagents/context streams answers and persists
each turn through the ContextEngine it is wired to. Text2SQL contributes
instructions() and AdapterIndexer — the caller composes them into a
ContextEngine and an agent, then passes that agent to chat(). Thread
identity comes from the chatId and userId on the engine. Use chat() when
you want a durable thread. toSql() stays stateless and only uses the
fragments you pass into that one call. Before each chat() call, append only
the new incoming message for that turn with context.continue(...).
Basic Usage
import {
ContextEngine,
agent,
chat,
errorRecoveryGuardrail,
user,
} from '@deepagents/context';
import { AdapterIndexer, instructions } from '@deepagents/text2sql';
const context = new ContextEngine({ store, chatId, userId });
const indexer = new AdapterIndexer({ adapters, version: 'v1' });
context.set(...instructions(), ...(await indexer.index()));
const ai = agent({
name: 'sql-assistant',
sandbox,
model,
context,
guardrails: [errorRecoveryGuardrail],
maxGuardrailRetries: 3,
});
await context.continue(user('Show me top 10 customers'));
const stream = await chat(ai, { generateTitle: true });
for await (const chunk of stream) {
// render
}That context.continue(...) call should contain only the current turn payload,
not the full thread transcript.
Indexing Progress
Schema indexing can be expensive on large databases. AdapterIndexer#index() accepts
an onProgress callback that emits typed progress events. Wrap indexing and
the chat stream together with createUIMessageStream from ai, so the client
sees progress before the assistant reply starts:
import { createUIMessageStream } from 'ai';
import { chat, user } from '@deepagents/context';
import {
AdapterIndexer,
TEXT2SQL_INDEX_PROGRESS_CHUNK,
instructions,
} from '@deepagents/text2sql';
const indexer = new AdapterIndexer({ adapters, version: 'v1' });
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const head = await context.headMessage();
if (head?.name === 'assistant')
writer.write({ type: 'start', messageId: head.id });
const fragments = await indexer.index({
onProgress: (event) =>
writer.write({ type: TEXT2SQL_INDEX_PROGRESS_CHUNK, data: event }),
});
context.set(...instructions(), ...fragments);
writer.merge(await chat(ai));
},
});Progress events include index:start, index:end, adapter:start,
adapter:end, adapter:cache-hit, adapter:cache-miss, phase:start,
phase:progress, phase:end, adapter:error, and index:error.
Use the Unix epoch timestampMs to derive durations in the client. For example,
subtract the timestampMs on a phase:start event from the matching
phase:end event for that adapter and phase. Text2SQL does not emit
elapsedMs, because UIs may want to group timing by index, adapter, phase,
table, or their own display model.
When you run indexing via the sandbox CLI (sql index), stdout still returns
the manifest JSON (fragmentsPath, eventsPath, adapter list). Add
--verbose pretty or --verbose json to mirror live progress to stderr, and
use --out-dir <path> (or TEXT2SQL_OUT_DIR) to control artifact location.
By default, sql index covers all configured adapters; pass adapter names to
scope it.
For cross-run cache control, set TEXT2SQL_INDEX_VERSION. Cache keys are
derived as index-<version>-<adapter>, so bump the version when the schema
changes.
To live-tail progress from another process, set TEXT2SQL_INDEX_EVENTS_PATH
to a fixed path. See
Getting Started — sql index options
for the mkfifo setup.
Reusing the Same Thread
Reuse the same store plus the same chatId and userId whenever you want a
follow-up request to see prior messages:
import { groq } from '@ai-sdk/groq';
import {
ContextEngine,
SqliteContextStore,
agent,
chat,
createBashTool,
createDockerSandbox,
errorRecoveryGuardrail,
npm,
user,
} from '@deepagents/context';
import { createSqlCommandHooks, instructions } from '@deepagents/text2sql';
const store = new SqliteContextStore('./text2sql-chat.sqlite');
const model = groq('openai/gpt-oss-20b');
async function createThread(chatId: string, userId: string) {
const backend = await createDockerSandbox({
installers: [npm('@deepagents/text2sql', { ensureRuntime: true })],
env: {
TEXT2SQL_ADAPTERS: '/workspace/text2sql-adapters.ts',
},
});
const sandbox = await createBashTool({
sandbox: backend,
...createSqlCommandHooks({ adapters }),
});
const context = new ContextEngine({ store, chatId, userId });
const indexResult = await sandbox.sandbox.executeCommand('sql index');
if (indexResult.exitCode !== 0) throw new Error(indexResult.stderr);
const manifest = JSON.parse(indexResult.stdout) as { fragmentsPath: string };
const fragments = JSON.parse(
await sandbox.sandbox.readFile(manifest.fragmentsPath),
);
context.set(...instructions(), ...fragments);
const ai = agent({
name: 'sql-assistant',
sandbox,
model,
context,
guardrails: [errorRecoveryGuardrail],
maxGuardrailRetries: 3,
});
return { context, ai };
}
const firstTurn = await createThread('chat-123', 'user-456');
await firstTurn.context.continue(user('Show me total revenue'));
for await (const _ of await chat(firstTurn.ai)) {
// drain or stream chunks to your UI
}
const followUp = await createThread('chat-123', 'user-456');
await followUp.context.continue(user('Break that down by quarter'));
for await (const _ of await chat(followUp.ai)) {
// drain or stream chunks to your UI
}TEXT2SQL_ADAPTERS must resolve inside the sandbox to a module whose default
export is your adapter map. Mount, upload, or write that file before the first
chat() call.
How It Works
When you call chat(ai):
- Load stored context - The
ContextEngineresolves prior messages for the same thread. - Inject SQL guidance - Text2SQL's
instructions()and indexed schema fragments are already in the engine. - Run tools - The agent can validate SQL, execute read-only queries, and inspect saved results.
- Persist the turn -
context.continue(...)saves the incoming message and reserves the assistant slot before streaming starts, then the assistant reply plus chat metadata are finalized when the turn finishes.
Message Format
ContextEngine.continue() accepts ChatMessage from @deepagents/context. In
most apps, user('...') or user(uiMessage) is enough. When prior turns are
already stored in your ContextEngine, do not resend them:
await context.continue(user('Show me sales by region'));
const stream = await chat(ai);If your UI keeps a full transcript client-side, strip it down to the new
message for this request before calling context.continue(...).
Options
The chat() helper from @deepagents/context accepts an optional options
object. These options belong to that helper, not to Text2Sql:
| Option | Type | Description |
|---|---|---|
abortSignal | AbortSignal | Cancels title generation, repair attempts, and the active stream |
generateTitle | boolean | Uses the model to generate the first chat title instead of the static fallback |
Abort Signal
const controller = new AbortController();
await context.continue(user('Show me top 10 customers'));
const stream = await chat(ai, { abortSignal: controller.signal });
setTimeout(() => controller.abort(), 10_000);The same signal is forwarded to title generation and tool-call repair, so cancellation stops the full turn rather than only the visible stream.
Error Handling
Tool and validation errors are surfaced as user-facing stream messages. After a
cancellation or failure, start a new chat() call. Reuse the same chatId to
continue the thread, or use a new chatId to start a separate conversation.
Integration Example
app.post('/api/chat', async (req, res) => {
const { message, chatId } = req.body;
const userId = req.user.id;
const { context, ai } = await createThread(chatId, userId);
await context.continue(user(message));
const stream = await chat(ai, { generateTitle: true });
res.setHeader('Content-Type', 'text/event-stream');
for await (const chunk of stream) {
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
}
res.end();
});message should be only the new turn payload for this request.
Best Practices
- Keep
chatIdstable per conversation so follow-up turns can resolve the same history. - Keep
userIdstable per signed-in user so related chats remain attributable. - Share one store across requests if you want persistence between processes or server restarts.
- Create a fresh
chatIdfor a new topic instead of overloading an existing thread.