History
Overview
Section titled “Overview”AgentKit enables persistent conversations that maintain context across multiple runs. By implementing a History Adapter, you can connect your agents and networks to any database or storage solution, allowing conversations to resume exactly where they left off.
A History Adapter is a configuration object that bridges AgentKit’s execution lifecycle with your database. It tells AgentKit how to:
- Create new conversation threads
- Load existing conversation history
- Save new messages and results
AgentKit is database-agnostic. You can use PostgreSQL, MongoDB, Redis, or any
storage solution by implementing the HistoryConfig interface.
The adapter is passed to createAgent() or createNetwork() and AgentKit automatically calls your adapter’s methods at the appropriate times during execution.
HistoryConfig Interface
Section titled “HistoryConfig Interface”The HistoryConfig interface has four optional methods. Below is an expanded view of the interface showing the context and parameters passed to each method.
import type { State, NetworkRun, AgentResult, GetStepTools, StateData,} from "@inngest/agent-kit";
interface HistoryConfig<T extends StateData> { /** * Creates a new conversation thread or ensures it exists. * Invoked at the start of a run to initialize the thread. */ createThread?: (ctx: { state: State<T>; // The current state, including your custom data input: string; // The user's input string network?: NetworkRun<T>; // The network instance (if applicable) step?: GetStepTools; // Inngest step tools for durable execution }) => Promise<{ threadId: string }>;
/** * Retrieves conversation history from your database. * Invoked after thread initialization if no history is provided by the client. */ get?: (ctx: { threadId?: string; // The ID of the conversation thread state: State<T>; input: string; network: NetworkRun<T>; step?: GetStepTools; }) => Promise<AgentResult[]>;
/** * Saves the user's message at the beginning of a run. * Invoked immediately after thread initialization, before any agents run. */ appendUserMessage?: (ctx: { threadId?: string; userMessage: { id: string; // Canonical, client-generated message ID content: string; role: "user"; timestamp: Date; }; state: State<T>; input: string; network: NetworkRun<T>; step?: GetStepTools; }) => Promise<void>;
/** * Saves new agent results to your database after a run. * Invoked at the end of a successful agent or network run. */ appendResults?: (ctx: { threadId?: string; newResults: AgentResult[]; // The new results generated during this run state: State<T>; input: string; network: NetworkRun<T>; step?: GetStepTools; }) => Promise<void>;}createThread
Section titled “createThread”- Creates a new conversation thread in your database or ensures an existing thread is present
- Invoked at the start of a run to initialize the thread
- Important: If a
threadIdalready exists in the state, your adapter should upsert (insert or update) to ensure the thread exists in storage - Returns an object with the
threadId
- Retrieves conversation history from your database
- Invoked after thread initialization, but only if:
- A
threadIdis present in the state - The client didn’t provide
resultsormessages - The thread was not just created in this run (client provided the threadId)
- A
- Returns an array of
AgentResult[]representing the conversation history - Recommended: Include both user messages and agent results by converting user messages to
AgentResultobjects withagentName: "user"to preserve conversation order
appendUserMessage
Section titled “appendUserMessage”- Saves the user’s message immediately at the beginning of a run
- Invoked after thread initialization but before any agents execute
- Ensures user intent is captured even if the agent run fails (enables “regenerate” workflows)
- Receives the user’s message with a canonical, client-generated ID for idempotency
appendResults
Section titled “appendResults”- Saves new agent results to your database after a network or agent run
- Invoked at the end of a successful agent or network run
- Receives only the new results generated during this run (AgentKit automatically filters out historical results to prevent duplicates)
Here’s a complete example of creating a network with history persistence:
import { createNetwork, createAgent, createState, openai,} from "@inngest/agent-kit";import { db } from "./db"; // Your database client
// Define your history adapter with all four methodsconst conversationHistoryAdapter: HistoryConfig<any> = { // 1. Create new conversation threads (or ensure they exist) createThread: async ({ state, input }) => { // If a threadId already exists, upsert to ensure it's in the database if (state.threadId) { await db.thread.upsert({ where: { id: state.threadId }, update: { updatedAt: new Date() }, create: { id: state.threadId, userId: state.data.userId, title: input.slice(0, 50), createdAt: new Date(), }, }); return { threadId: state.threadId }; }
// Otherwise, create a new thread const thread = await db.thread.create({ data: { userId: state.data.userId, title: input.slice(0, 50), // First 50 chars as title createdAt: new Date(), }, }); return { threadId: thread.id }; },
// 2. Load conversation history (including user messages) get: async ({ threadId }) => { if (!threadId) return [];
const messages = await db.message.findMany({ where: { threadId }, orderBy: { createdAt: "asc" }, });
// Transform ALL messages (user + agent) to AgentResult format // This preserves the complete conversation order return messages.map((msg) => { if (msg.role === "user") { // Convert user messages to AgentResult with agentName: "user" return new AgentResult( "user", [ { type: "text" as const, role: "user" as const, content: msg.content, stop_reason: "stop", }, ], [], new Date(msg.createdAt) ); } else { // Return agent results return new AgentResult( msg.agentName, [ { type: "text" as const, role: "assistant" as const, content: msg.content, }, ], [], new Date(msg.createdAt) ); } }); },
// 3. Save user message immediately (before agents run) appendUserMessage: async ({ threadId, userMessage }) => { if (!threadId) return;
await db.message.create({ data: { messageId: userMessage.id, // Use canonical client-generated ID threadId, role: "user", content: userMessage.content, createdAt: userMessage.timestamp, }, }); },
// 4. Save agent results after the run appendResults: async ({ threadId, newResults }) => { if (!threadId) return;
// Save only agent responses (user message already saved) for (const result of newResults) { const content = result.output .filter((msg) => msg.type === "text") .map((msg) => msg.content) .join("\n");
await db.message.create({ data: { messageId: result.id || crypto.randomUUID(), // Use result.id if available threadId, role: "assistant", agentName: result.agentName, content, checksum: result.checksum, // For idempotency createdAt: result.createdAt, }, }); } },};Once you’ve created your adapter, pass it to the history property when creating an agent or network:
import { createAgent } from "@inngest/agent-kit";import { postgresHistoryAdapter } from "./my-postgres-adapter";
const chatAgent = createAgent({ name: "chat-agent", system: "You are a helpful assistant.", history: postgresHistoryAdapter, // Add your adapter here});
// Now the agent will automatically persist conversationsawait chatAgent.run("Hello!", { state: createState({ userId: "user123" }, { threadId: "thread-456" }),});import { createNetwork, createAgent } from "@inngest/agent-kit";import { postgresHistoryAdapter } from "./my-postgres-adapter";
const chatAgent = createAgent({ name: "chat-agent", system: "You are a helpful assistant.",});
const chatNetwork = createNetwork({ name: "Chat Network", agents: [chatAgent], history: postgresHistoryAdapter, // Add your adapter here});
// The entire network will use persistent conversationsawait chatNetwork.run("Hello!");Persistence Patterns
Section titled “Persistence Patterns”AgentKit supports two distint patterns for managing conversation history.
Server-Authoritative
Section titled “Server-Authoritative”The client sends a message with a threadId. AgentKit automatically loads the full conversation context from your database before the network runs.
// Client sends just the threadIdconst state = createState( { userId: "user123" }, { threadId: "existing-thread-id" });
await chatNetwork.run("Continue our conversation", { state });// AgentKit calls history.get() to load full context for all agentsUse case: Perfect for restoring conversations after page refresh or when opening the app on a new device.
Client-Authoritative (Performance Optimized)
Section titled “Client-Authoritative (Performance Optimized)”The client maintains conversation state locally and sends the complete history with each request. AgentKit detects this and skips the database read for better performance.
// Client sends the full conversation historyconst state = createState( { userId: "user123" }, { threadId: "thread-id", results: previousConversationResults, // Full history from client });
await chatNetwork.run("New message", { state });// AgentKit skips history.get() call - faster performance!// Still calls appendUserMessage() and appendResults() to save new messagesUse case: Ideal for interactive chat applications where the frontend maintains conversation state and fetches messages from an existing/separate API
Note: Providing either results or messages to createState will disable the history.get() call, enabling this client-authoritative pattern.
Server/Client Hybrid Pattern
Section titled “Server/Client Hybrid Pattern”You can combine the Server-Authoritative and Client-Authoritative patterns for an optimal user experience. This hybrid approach allows for fast initial conversation loading and high-performance interactive chat.
- Initial Load (Server-Authoritative): When a user opens a conversation thread, the client sends only the
threadId. AgentKit fetches the history from your database usinghistory.get(). The application then hydrates the client-side state with this history. - Interactive Session (Client-Authoritative): For all subsequent requests within the session, the client sends the full, up-to-date history (
resultsormessages) along with thethreadId. AgentKit detects the client-provided history and skips the database read, resulting in a faster response.
Use case: Ideal for interactive chat applications where the frontend maintains conversation state but lets AgentKit fetch messages via their history adapter
How Thread IDs Are Managed
Section titled “How Thread IDs Are Managed”AgentKit offers a flexible system for managing conversation thread IDs, ensuring that history is handled correctly whether you’re starting a new conversation or continuing an existing one. Here’s how AgentKit determines which threadId to use:
Thread Initialization Flow
Section titled “Thread Initialization Flow”| Scenario | threadId provided? | createThread exists? | Behavior |
|---|---|---|---|
| Resume existing thread | Yes | Yes | Calls createThread to upsert/ensure thread exists in DB |
| Resume existing thread | Yes | No | Uses provided threadId directly |
| New conversation | No | Yes | Calls createThread to create new thread and get threadId |
| New conversation (fallback) | No | No (but get exists) | Auto-generates UUID as threadId |
-
Explicit
threadIdwithcreateThread: When you provide athreadIdand your adapter has acreateThreadmethod, AgentKit callscreateThreadto ensure the thread exists in your database. Your adapter should implement an upsert pattern (insert if new, update if exists) to handle both new and existing threads gracefully.// Continue a specific, existing conversationconst state = createState({ userId: "user-123" },{ threadId: "existing-thread-id-123" });await network.run("Let's pick up where we left off.", { state });// createThread is called to ensure thread exists in DB// Then history.get() loads the conversation history -
Automatic Creation via
createThread: If you don’t provide athreadId, AgentKit checks if your history adapter has acreateThreadmethod. If so, AgentKit calls it to create a new conversation thread in your database. YourcreateThreadfunction is responsible for generating and returning the new uniquethreadId. This is the recommended approach for starting new conversations, as it ensures a record is created in your backend from the very beginning.// Start a new conversationconst state = createState({ userId: "user-123" });await network.run("Hello!", { state });// createThread is called to create a new thread// state.threadId is set to the new thread ID -
Automatic Generation (Fallback): In cases where you don’t provide a
threadIdand your history adapter does not have acreateThreadmethod but does have agetmethod, AgentKit provides a fallback. It will automatically generate a standard UUID and assign it as thethreadIdfor the current run. This convenience ensures the conversation can proceed with a unique identifier for saving and loading history, even without an explicit creation step.// Fallback: UUID is generated automaticallyconst state = createState({ userId: "user-123" });await network.run("Hello!", { state });// state.threadId is set to a new UUID// appendUserMessage and appendResults can use this ID
Best Practices
Section titled “Best Practices”Implement Idempotency with Message IDs and Checksums
Use unique constraints on message_id and checksum to prevent duplicate messages during retries or streaming scenarios.
CREATE TABLE messages ( id SERIAL PRIMARY KEY, message_id UUID NOT NULL, thread_id UUID NOT NULL, message_type TEXT NOT NULL, -- 'user' or 'agent' content TEXT, checksum TEXT NOT NULL, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(thread_id, message_id), -- Prevent duplicate message IDs UNIQUE(thread_id, checksum) -- Prevent duplicate content);appendUserMessage: async ({ threadId, userMessage }) => { await db.message.create({ data: { messageId: userMessage.id, // Use canonical client ID threadId, content: userMessage.content, checksum: generateChecksum(userMessage), }, });},
appendResults: async ({ threadId, newResults }) => { for (const result of newResults) { await db.message.create({ data: { messageId: result.id || crypto.randomUUID(), threadId, checksum: result.checksum, // Built-in checksum // ... other fields }, }); }}Leverage Inngest’s Durable Steps
Wrap database operations in step.run() for automatic retries and durability.
appendUserMessage: async ({ threadId, userMessage, step }) => { if (step) { return await step.run("save-user-message", async () => { return await db.saveMessage(threadId, userMessage); }); } return await db.saveMessage(threadId, userMessage);}Handle Missing Threads Gracefully
If a thread doesn’t exist, return an empty array rather than throwing an error.
get: async ({ threadId }) => { if (!threadId) return [];
const messages = await db.getMessages(threadId); return messages || []; // Handle null/undefined gracefully}Index Your Database Properly
Ensure you have indexes on key columns for fast queries.
CREATE INDEX idx_messages_thread_id ON messages(thread_id);CREATE INDEX idx_messages_created_at ON messages(created_at);CREATE INDEX idx_messages_type ON messages(message_type);CREATE INDEX idx_messages_message_id ON messages(message_id);Return Complete Conversation History
Include both user messages and agent results in your get() method to preserve conversation order.
get: async ({ threadId }) => { const messages = await db.message.findMany({ where: { threadId }, orderBy: { createdAt: "asc" }, });
// Convert ALL messages (user + agent) to AgentResult format return messages.map((msg) => { if (msg.role === "user") { return new AgentResult("user", [ { type: "text", role: "user", content: msg.content } ], [], new Date(msg.createdAt)); } else { return new AgentResult(msg.agentName, [ { type: "text", role: "assistant", content: msg.content } ], [], new Date(msg.createdAt)); } });}Implement Upsert in createThread
Handle both new and existing threads gracefully by implementing an upsert pattern.
createThread: async ({ state }) => { if (state.threadId) { // Upsert: ensure existing thread is in DB await db.thread.upsert({ where: { id: state.threadId }, update: { updatedAt: new Date() }, create: { id: state.threadId, userId: state.data.userId }, }); return { threadId: state.threadId }; }
// Create new thread const thread = await db.thread.create({ data: { userId: state.data.userId }, }); return { threadId: thread.id };}Future Enhancements
Section titled “Future Enhancements”The history system provides a foundation for advanced features to be released in the coming future including:
- Database Adapters: Pre-built adapters for popular databases (coming soon)
- Progressive Summarization: Automatic conversation compression for long threads
- Search & Retrieval: Semantic search across conversation history
Complete Example
Section titled “Complete Example”Check out the AgentKit Starter for a complete implementation featuring:
- PostgreSQL history adapter
- ChatGPT-style UI with thread management
- Real-time streaming responses
- Both server and client-authoritative patterns
The starter includes everything you need to build a conversational AI application with persistent history.