Messages
The Agent component stores message and thread history to enable conversations between humans and agents.
To see how humans can act as agents, see Human Agents.
Retrieving messages
For clients to show messages, you need to expose a query that returns the messages. For streaming, see retrieving streamed deltas for a modified version of this query.
See chat/basic.ts for the server-side code, and chat/streaming.ts for the streaming example.
import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values";
import { listUIMessages } from "@convex-dev/agent";
import { components } from "./_generated/api";
export const listThreadMessages = query({
args: { threadId: v.string(), paginationOpts: paginationOptsValidator },
handler: async (ctx, args) => {
await authorizeThreadAccess(ctx, threadId);
const paginated = await listUIMessages(ctx, components.agent, args);
// Here you could filter out / modify the documents
return paginated;
},
});
Note: Above we used listUIMessages
, which returns UIMessages, specifically the
Agent extension that includes some extra fields like order, status, etc.
UIMessages combine multiple MessageDocs into a single UIMessage when there are
multiple tool calls followed by an assistant message, making it easy to build
UIs that work with the various "parts" on the UIMessage.
If you want to get MessageDocs, you can use listMessages
instead.
Showing messages in React
See ChatStreaming.tsx for a streaming example, or ChatBasic.tsx for a non-streaming example.
useUIMessages
hook
The crux is to use the useUIMessages
hook. For streaming, pass in
stream: true
to the hook.
import { api } from "../convex/_generated/api";
import { useUIMessages } from "@convex-dev/agent/react";
function MyComponent({ threadId }: { threadId: string }) {
const { results, status, loadMore } = useUIMessages(
api.chat.streaming.listMessages,
{ threadId },
{ initialNumItems: 10 /* stream: true */ },
);
return (
<div>
{results.map((message) => (
<div key={message.key}>{message.text}</div>
))}
</div>
);
}
Note: If you want to work with MessageDocs instead of UIMessages, you can use
the older useThreadMessages
hook instead. However, working with UIMessages
enables richer streaming capabilities, such as status on whether the agent is
actively reasoning.
UIMessage type
The Agent component extends the AI SDK's UIMessage
type to provide convenient
metadata for rendering messages.
The core UIMessage type from the AI SDK is:
parts
is an array of parts (e.g. "text", "file", "image", "toolCall", "toolResult")content
is a string of the message content.role
is the role of the message (e.g. "user", "assistant", "system").
The helper adds these additional fields:
key
is a unique identifier for the message.order
is the order of the message in the thread.stepOrder
is the step order of the message in the thread.status
is the status of the message (or "streaming").agentName
is the name of the agent that generated the message.text
is the text of the message._creationTime
is the timestamp of the message. For streaming messages, it's currently assigned to the current time on the streaming client.
To reference these, ensure you're importing UIMessage
from
@convex-dev/agent
.
toUIMessages
helper
toUIMessages
is a helper function that transforms MessageDocs into AI SDK
"UIMessage"s. This is a convenient data model for displaying messages.
If you are using useThreadMessages
for instance, you can convert the messages
to UIMessages like this:
import { toUIMessages, type UIMessage } from "@convex-dev/agent";
...
const { results } = useThreadMessages(...);
const uiMessages = toUIMessages(results);
Optimistic updates for sending messages
The optimisticallySendMessage
function is a helper function for sending a
message, so you can optimistically show a message in the message list until the
mutation has completed on the server.
Pass in the query that you're using to list messages, and it will insert the ephemeral message at the top of the list.
const sendMessage = useMutation(
api.streaming.streamStoryAsynchronously,
).withOptimisticUpdate(
optimisticallySendMessage(api.streaming.listThreadMessages),
);
If your arguments don't include { threadId, prompt }
then you can use it as a
helper function in your optimistic update:
import { optimisticallySendMessage } from "@convex-dev/agent/react";
const sendMessage = useMutation(
api.chatStreaming.streamStoryAsynchronously,
).withOptimisticUpdate(
(store, args) => {
optimisticallySendMessage(api.chatStreaming.listThreadMessages)(store, {
threadId:
prompt: /* change your args into the user prompt. */,
})
}
);
Saving messages
By default, the Agent will save messages to the database automatically when you provide them as a prompt, as well as all generated messages.
However, it is useful to save the prompt message ahead of time and use the
promptMessageId
to continue the conversation. See
Agent Usage for more details.
You can save messages to the database manually using saveMessage
or
saveMessages
, either on the Agent class or as a direct function call.
- You can pass a
prompt
or a fullmessage
(ModelMessage
type) - The
metadata
argument is optional and allows you to provide more details, such assources
,reasoningDetails
,usage
,warnings
,error
, etc.
const { messageId } = await saveMessage(ctx, components.agent, {
threadId,
userId,
message: { role: "user", content: "The user message" },
});
Note: when calling agent.generateText
with the raw prompt, embeddings are
generated automatically for vector search (if you have a text embedding model
configured). Similarly with agent.saveMessage
when calling from an action.
However, if you're saving messages in a mutation, where calling an LLM is not
possible, it will generate them automatically if generateText
receives a
promptMessageId
that lacks an embedding (and you have a text embedding model
configured).
Without the Agent class:
Note: If you aren't using the Agent class with a text embedding model set, you
need to pass an embedding
if you want to save it at the same time.
import { saveMessage } from "@convex-dev/agent";
const { messageId } = await saveMessage(ctx, components.agent, {
threadId,
userId,
message: { role: "assistant", content: result },
metadata: [{ reasoning, usage, ... }] // See MessageWithMetadata type
agentName: "my-agent",
embedding: { vector: [0.1, 0.2, ...], model: "text-embedding-3-small" },
});
Using the Agent class:
const { messageId } = await agent.saveMessage(ctx, {
threadId,
userId,
prompt,
metadata,
});
const { messages } = await agent.saveMessages(ctx, {
threadId, userId,
messages: [{ role, content }],
metadata: [{ reasoning, usage, ... }] // See MessageWithMetadata type
});
If you are saving the message in a mutation and you have a text embedding model
set, pass skipEmbeddings: true
. The embeddings for the message will be
generated lazily if the message is used as a prompt. Or you can provide an
embedding upfront if it's available, or later explicitly generate them using
agent.generateEmbeddings
.
Configuring the storage of messages
Generally the defaults are fine, but if you want to pass in multiple messages
and have them all saved (vs. just the last one), or avoid saving any input or
output messages, you can pass in a storageOptions
object, either to the Agent
constructor or per-message.
The use-case for passing in multiple messages but not saving them is if you want
to include some extra messages for context to the LLM, but only the last message
is the user's actual request. e.g.
messages = [...messagesFromRag, messageFromUser]
. The default is to save the
prompt and all output messages.
const result = await thread.generateText({ messages }, {
storageOptions: {
saveMessages: "all" | "none" | "promptAndOutput";
},
});
Message ordering
Each message has order
and stepOrder
fields, which are incrementing integers
specific to a thread.
When saveMessage
or generateText
is called, the message is added to the
thread's next order
with a stepOrder
of 0.
As response message(s) are generated in response to that message, they are added
at the same order
with the next stepOrder
.
To associate a response message with a previous message, you can pass in the
promptMessageId
to generateText
and others.
Note: if the promptMessageId
is not the latest message in the thread, the
context for the message generation will not include any messages following the
promptMessageId
.
Deleting messages
You can delete messages by their _id
(returned from saveMessage
or
generateText
) or order
/ stepOrder
.
By ID:
await agent.deleteMessage(ctx, { messageId });
// batch delete
await agent.deleteMessages(ctx, { messageIds });
By order (start is inclusive, end is exclusive):
// Delete all messages with the same order as a given message:
await agent.deleteMessageRange(ctx, {
threadId,
startOrder: message.order,
endOrder: message.order + 1,
});
// Delete all messages with order 1 or 2.
await agent.deleteMessageRange(ctx, { threadId, startOrder: 1, endOrder: 3 });
// Delete all messages with order 1 and stepOrder 2-4
await agent.deleteMessageRange(ctx, {
threadId,
startOrder: 1,
startStepOrder: 2,
endOrder: 2,
endStepOrder: 5,
});
Other utilities:
import { ... } from "@convex-dev/agent";
serializeDataOrUrl
is a utility function that serializes an AI SDKDataContent
orURL
to a Convex-serializable format.filterOutOrphanedToolMessages
is a utility function that filters out tool call messages that don't have a corresponding tool result message.extractText
is a utility function that extracts text from aModelMessage
-like object.
Validators and types
There are types to validate and provide types for various values
import { ... } from "@convex-dev/agent";
vMessage
is a validator for aModelMessage
-like object (with arole
andcontent
field e.g.).MessageDoc
andvMessageDoc
are the types for a message (which includes a.message
field with thevMessage
type).Thread
is the type of a thread returned fromcontinueThread
orcreateThread
.ThreadDoc
andvThreadDoc
are the types for thread metadata.AgentComponent
is the type of the installed component (e.g.components.agent
).ToolCtx
is thectx
type for calls tocreateTool
tools.