Skip to main content

Human Agents

For AI agents: see llms.txt for the complete documentation index. Markdown versions are available by adding .md to a page URL or requesting Accept: text/markdown.

The Agent component generally takes a prompt from a human or agent, and uses an LLM to generate a response.

However, there are cases where you want to generate the reply from a human acting as an agent, such as for customer support.

For full code, check out chat/human.ts

Saving a user message without generating a reply​

You can save a message from a user without generating a reply by using the saveMessage function.

import { saveMessage } from "@convex-dev/agent";
import { components } from "./_generated/api";

await saveMessage(ctx, components.agent, {
threadId,
prompt: "The user message",
});

Saving a message from a human as an agent​

Similarly, you can save a message from a human as an agent in the same way, using message for the role and content and agentName for attribution. To render a standalone operator reply separately from the previous agent turn, pass order: "next":

import { saveMessage } from "@convex-dev/agent";
import { components } from "./_generated/api";

await saveMessage(ctx, components.agent, {
threadId,
order: "next",
agentName: "Alex",
message: { role: "assistant", content: "The human reply" },
});

order: "next" allocates the next order atomically. Passing a number places the reply at that exact order. An unused numeric order starts at stepOrder: 0; an existing order appends at its next stepOrder.

Storing additional metadata about human agents​

You can store additional metadata about human agents by using the saveMessage function, and adding the metadata field.

import { saveMessage } from "@convex-dev/agent";
import { components } from "./_generated/api";

await saveMessage(ctx, components.agent, {
threadId,
order: "next",
agentName: "Alex",
message: { role: "assistant", content: "The human reply" },
metadata: {
provider: "human",
providerMetadata: {
human: {
/* ... */
},
},
},
});

Deciding who responds next​

You can choose whether the LLM or human responds next in a few ways:

  1. Explicitly store in the database whether the user or LLM is assigned to the thread.
  2. Using a call to a cheap and fast LLM to decide if the user question requires a human response.
  3. Using vector embeddings of the user question and message history to make the decision, based on a corpus of sample questions and what questions are better handled by humans.
  4. Have the LLM generate an object response that includes a field indicating whether the user question requires a human response.
  5. Providing a tool to the LLM to decide if the user question requires a human response. The human response is then the tool response message.

Human responses as tool calls​

You can have the LLM generate a tool call to a human agent to provide context to answer the user question by providing a tool that doesn't have a handler. Note: this generally happens when the LLM still intends to answer the question, but needs human intervention to do so, such as confirmation of a fact.

import { tool } from "ai";
import { z } from "zod/v3";

const askHuman = tool({
description: "Ask a human a question",
inputSchema: z.object({
question: z.string().describe("The question to ask the human"),
}),
});

export const ask = action({
args: { question: v.string(), threadId: v.string() },
handler: async (ctx, { question, threadId }) => {
const result = await agent.generateText(
ctx,
{ threadId },
{
prompt: question,
tools: { askHuman },
},
);
const supportRequests = result.toolCalls
.filter((tc) => tc.toolName === "askHuman" && !tc.dynamic)
.map(({ toolCallId, input: { question } }) => ({
toolCallId,
question,
}));
if (supportRequests.length > 0) {
// Do something so the support agent knows they need to respond,
// e.g. save a message to their inbox
// await ctx.runMutation(internal.example.sendToSupport, {
// threadId,
// supportRequests,
// });
}
},
});

export const humanResponseAsToolCall = internalAction({
args: {
humanName: v.string(),
response: v.string(),
toolCallId: v.string(),
threadId: v.string(),
messageId: v.string(),
},
handler: async (ctx, args) => {
await agent.saveMessage(ctx, {
threadId: args.threadId,
message: {
role: "tool",
content: [
{
type: "tool-result",
output: { type: "text", value: args.response },
toolCallId: args.toolCallId,
toolName: "askHuman",
},
],
},
metadata: {
provider: "human",
providerMetadata: {
human: { name: args.humanName },
},
},
});
// Continue generating a response from the LLM
await agent.generateText(
ctx,
{ threadId: args.threadId },
{
promptMessageId: args.messageId,
},
);
},
});