Deep Agents
AgentContextOrchestratorRetrievalText2SQLToolbox

Chat Management

Create, update, list, and delete chats with metadata and usage tracking

A chat is the top-level container for a conversation. It holds messages, branches, and checkpoints in a graph-based store. Every ContextEngine instance is scoped to a single chat.

Creating a Chat

Chats are created automatically when ContextEngine first initializes (on save() or resolve()). You provide a chatId and userId:

import {
  ContextEngine,
  SqliteContextStore,
  XmlRenderer,
  role,
  user,
  assistant,
} from '@deepagents/context';

const store = new SqliteContextStore('./app.db');

const context = new ContextEngine({
  store,
  chatId: 'chat-001',
  userId: 'user-001',
  metadata: { source: 'web' },
});

context.set(role('You are a helpful assistant.'), user('Hello!'));
await context.save();

The engine calls upsertChat() internally, so re-using the same chatId resumes the existing chat rather than creating a duplicate. The metadata option in the constructor is merged into the chat on first initialization.

Chat ID Strategies

const chatId = crypto.randomUUID();

const chatId = `user-${userId}-${Date.now()}`;

const chatId = `chat-${nextId++}`;

Accessing Chat Metadata

The chat property returns a ChatMeta object after the engine has initialized (after any save() or resolve() call). Before initialization it returns null.

const context = new ContextEngine({ store, chatId: 'chat-001', userId: 'user-001' });

context.chat; // null (not yet initialized)

await context.save();

context.chat;
// {
//   id: 'chat-001',
//   userId: 'user-001',
//   createdAt: 1703123456789,
//   updatedAt: 1703123456789,
//   title: undefined,
//   metadata: undefined,
// }

context.chatId; // 'chat-001'

ChatMeta

FieldTypeDescription
idstringChat identifier
userIdstringOwner of the chat
createdAtnumberUnix timestamp (ms)
updatedAtnumberUnix timestamp (ms), auto-updated
titlestring | undefinedUser-provided title
metadataRecord<string, unknown> | undefinedCustom key-value data

Updating Chat Metadata

Use updateChat() to set a title or attach custom metadata:

await context.updateChat({
  title: 'Help with TypeScript',
  metadata: {
    tags: ['coding', 'typescript'],
    priority: 'high',
  },
});

console.log(context.chat?.title); // 'Help with TypeScript'

Metadata is merged with existing values, not replaced:

await context.updateChat({ metadata: { category: 'support' } });
await context.updateChat({ metadata: { resolved: true } });

console.log(context.chat?.metadata);
// { category: 'support', resolved: true }

Automatic Title Generation

The chat() streaming function can automatically generate a title for new chats. When a chat has no title, the first user message is used to derive one through a two-phase approach.

Static Title

staticChatTitle() extracts the text content from a UIMessage and truncates it to 100 characters. This is applied immediately so the chat has a readable title without waiting for an LLM call.

import { staticChatTitle } from '@deepagents/context';

const title = staticChatTitle(userMessage);
// "help me write an essay about space exploration and..."

await context.updateChat({ title });

AI-Generated Title

generateChatTitle() uses an LLM to produce a short 2-5 word title. It falls back to staticChatTitle() if the LLM call fails.

import { generateChatTitle } from '@deepagents/context';

const title = await generateChatTitle({
  message: userMessage,
  model: myModel,
  abortSignal: controller.signal, // optional
});
// "Space Essay Help"

GenerateChatTitleOptions

OptionTypeRequiredDescription
messageUIMessageYesThe user message to summarize
modelAgentModelYesThe model used to generate the title
abortSignalAbortSignalNoSignal to cancel the generation

How chat() Uses Titles

When you call chat(), titles are handled automatically:

  1. If generateTitle: true and the agent has a model, an LLM call generates a 2-5 word title. If the LLM call fails, it falls back to staticChatTitle().
  2. If generateTitle is false (the default) or the agent has no model, staticChatTitle() is applied immediately.
  3. Once the title resolves, it is emitted as a { type: 'data-chat-title', data: title } event on the UI stream and persisted via context.updateChat().
import { chat } from '@deepagents/context';

const stream = await chat(agent, messages, {
  generateTitle: true, 
});

The ChatAgentLike interface exposes an optional model property. If model is not present on the agent, only the static title is applied.

The generateTitle option is part of ChatOptions, which also accepts contextVariables, transform, abortSignal, onError, messageMetadata, and finalAssistantMetadata.

Listing Chats

Use the store's listChats() method directly. Results are sorted by updatedAt descending (most recent first).

const chats = await store.listChats();

for (const chat of chats) {
  console.log(`${chat.id}: ${chat.title ?? 'Untitled'}`);
  console.log(`  Messages: ${chat.messageCount}, Branches: ${chat.branchCount}`);
  console.log(`  Updated: ${new Date(chat.updatedAt).toLocaleString()}`);
}

Filtering and Pagination

const chats = await store.listChats({
  userId: 'user-001',
  limit: 20,
  offset: 0,
});

Filter by a top-level metadata field with exact match:

const archived = await store.listChats({
  userId: 'user-001',
  metadata: { key: 'archived', value: true },
});

ListChatsOptions

OptionTypeDescription
userIdstringFilter by user
metadata{ key: string; value: string | number | boolean }Exact match on a top-level metadata field
limitnumberMax results
offsetnumberSkip N results (pagination)

ChatInfo

Each item returned from listChats() is a ChatInfo:

FieldTypeDescription
idstringChat identifier
userIdstringOwner
titlestring | undefinedTitle
metadataRecord<string, unknown> | undefinedCustom data
messageCountnumberTotal messages in the chat
branchCountnumberTotal branches
createdAtnumberUnix timestamp (ms)
updatedAtnumberUnix timestamp (ms)

Deleting a Chat

Delete a chat and all associated data (messages, branches, checkpoints):

const deleted = await store.deleteChat('chat-001');
// true if deleted, false if not found

Scope deletion to a specific user to prevent unauthorized deletes:

const deleted = await store.deleteChat('chat-001', { userId: 'user-001' });
// false if chat belongs to a different user

Usage Tracking

Track cumulative token usage across the lifetime of a chat with trackUsage(). It accumulates LanguageModelUsage from the AI SDK into chat.metadata.usage:

import { generateText } from 'ai';
import { groq } from '@ai-sdk/groq';

const context = new ContextEngine({ store, chatId: 'chat-001', userId: 'user-001' });
context.set(role('You are helpful.'), user('What is TypeScript?'));

const { systemPrompt, messages } = await context.resolve({ renderer: new XmlRenderer() });

const result = await generateText({
  model: groq('gpt-oss-20b'),
  system: systemPrompt,
  messages,
});

context.set(assistant(result.text));
await context.save();

await context.trackUsage(await result.usage);
console.log(context.chat?.metadata?.usage);
// { inputTokens: 45, outputTokens: 120, totalTokens: 165 }

Each call to trackUsage() adds to the running total. Concurrent calls are safe -- the method reads fresh data from the store before accumulating.

Multi-User Chat Patterns

Shared Store, User-Scoped Queries

const store = new SqliteContextStore('./app.db');

function getUserChats(userId: string) {
  return store.listChats({ userId, limit: 50 });
}

function createChat(userId: string) {
  return new ContextEngine({
    store,
    chatId: crypto.randomUUID(),
    userId,
  });
}

Safe Deletion

async function deleteUserChat(chatId: string, userId: string) {
  const deleted = await store.deleteChat(chatId, { userId });
  if (!deleted) {
    throw new Error('Chat not found or not owned by this user');
  }
}

Complete Example

import { generateText } from 'ai';
import { groq } from '@ai-sdk/groq';
import {
  ContextEngine,
  SqliteContextStore,
  XmlRenderer,
  role,
  user,
  assistant,
} from '@deepagents/context';

const store = new SqliteContextStore('./chats.db');

async function sendMessage(chatId: string, userId: string, text: string) {
  const context = new ContextEngine({ store, chatId, userId })
    .set(role('You are a helpful assistant.'));

  context.set(user(text));

  const { systemPrompt, messages } = await context.resolve({
    renderer: new XmlRenderer(),
  });

  const result = await generateText({
    model: groq('gpt-oss-20b'),
    system: systemPrompt,
    messages,
  });

  context.set(assistant(result.text));
  await context.save();

  if (!context.chat?.title) {
    await context.updateChat({ title: text.slice(0, 100) });
  }

  await context.trackUsage(await result.usage);

  return result.text;
}

async function listUserChats(userId: string) {
  return store.listChats({ userId, limit: 50 });
}

async function deleteChat(chatId: string, userId: string) {
  return store.deleteChat(chatId, { userId });
}

When using the chat() streaming function instead of generateText(), title generation is handled automatically -- pass generateTitle: true to enable AI-powered titles.

Next Steps

  • chat() - Streaming orchestration with automatic persistence, titles, and usage tracking
  • Context Engine - Full API reference
  • Checkpoints - Named restore points
  • Branching - Explore multiple conversation paths
  • Storage - Store implementations and schema