Multi-turn Conversations
Learn how to maintain context across multiple LLM calls using conversation history.
VernLLM supports multi-turn conversations by allowing you to provide previous user and assistant messages through the history option.
This allows the model to continue a conversation while keeping context from earlier messages.
Basic usage
await llm.call({
systemPrompt: 'You are a helpful assistant.',
history: [
{
role: 'user',
content: 'What is TypeScript?',
},
{
role: 'assistant',
content: 'TypeScript is a typed superset of JavaScript created by Microsoft.',
},
],
userContent: 'Who created it?',
});The model receives the conversation in this order:
- System prompt (if provided)
- Previous conversation history
- Current user message
The resulting message sequence is:
system
↓
user: What is TypeScript?
↓
assistant: TypeScript is a typed superset of JavaScript created by Microsoft.
↓
user: Who created it?History format
ConversationTurn is a discriminated union keyed on role, not a single flat shape. This is a
breaking change from earlier versions, where every turn was { role: 'user' | 'assistant'; content: string } regardless of what it represented.
type ConversationTurn =
| {
role: 'user';
content: string;
}
| {
role: 'assistant';
content?: string | JsonValue;
toolCalls?: ToolCall[];
}
| {
role: 'tool';
toolResults: ToolResult[];
};user turns still require content. assistant turns no longer require it, since an assistant
turn that only requested tools has no text of its own. Plain user/assistant history exactly like
before still works unchanged, this only adds the tool role and the optional toolCalls field on
top.
Assistant content also accepts a parsed JsonValue, so a prior jsonMode: true result can be
pushed back into history directly. This is common in chat apps, where history is declared once
and grows as the conversation continues:
import type { ConversationTurn } from 'vern-llm';
const history: ConversationTurn[] = [];
const parsed = await llm.call({ userContent: 'Give me a JSON summary.', jsonMode: true });
history.push(
{ role: 'user', content: 'Give me a JSON summary.' },
{ role: 'assistant', content: parsed },
);VernLLM's request construction stringifies non-string assistant content before it's sent as part
of the request, so no manual JSON.stringify call is needed.
For plain conversations without tools, only the user and assistant cases apply:
| Field | Description |
|---|---|
role | The sender of the message (user or assistant) |
content | The message content |
Example:
const history = [
{
role: 'user',
content: 'Explain async functions.',
},
{
role: 'assistant',
content: 'Async functions allow asynchronous code to be written using promise-based syntax.',
},
];Continuing a conversation
A typical chat application declares history once, typed as ConversationTurn[], and pushes each
turn as the conversation continues:
import type { ConversationTurn } from 'vern-llm';
const history: ConversationTurn[] = [];
const first = await llm.call({ userContent: 'What is Rust?', jsonMode: false });
history.push({ role: 'user', content: 'What is Rust?' }, { role: 'assistant', content: first });
const response = await llm.call({
history,
userContent: 'What makes it memory safe?',
jsonMode: false,
});
history.push(
{ role: 'user', content: 'What makes it memory safe?' },
{ role: 'assistant', content: response },
);The model can answer each follow-up because the growing history array is sent with every
request. Typing history as ConversationTurn[] up front also covers assistant content that's a
parsed JsonValue, not just a string, so pushing a jsonMode: true result needs no extra casting.
History validation
VernLLM validates history before sending a request.
History must:
- Only contain
user,assistant, andtoolroles. - Alternate between user and assistant turns, the same as before.
- End with either an
assistantturn without tool calls, or a completedtoolturn.
The current userContent is always appended after the history, so the final history entry cannot
be a user turn.
A tool turn has its own rules on top of the alternation check:
- It must immediately follow an
assistantturn that settoolCalls. - It must include exactly one
ToolResultfor every requestedtoolCallId, no more, no fewer. - An assistant turn that requested tools cannot be followed by anything except its matching
toolturn, including the final entry inhistoryitself.
Any violation throws LLMError('validation') before a request is sent. See Tool Calling for how this fits into a full request, execute,
continue loop.
Valid history
history: [
{
role: 'user',
content: 'What is Python?',
},
{
role: 'assistant',
content: 'Python is a programming language.',
},
];Invalid history
history: [
{
role: 'user',
content: 'What is Python?',
},
];The invalid example would create two consecutive user messages after userContent is added.
Empty history
history is optional.
Omitting it or passing an empty array behaves the same:
await llm.call({
history: [],
userContent: 'Hello!',
});Provider support
Conversation history is handled by VernLLM's core layer and converted into the correct format for each provider adapter.
User, assistant, and tool turns are all preserved, allowing multi-turn conversations, including
tool call continuations, to work consistently across supported LLM providers. Each adapter maps
tool turns onto whatever shape that provider expects, for example Anthropic's tool_result
content blocks or Gemini's functionResponse parts, so you write history once in VernLLM's
shape regardless of which provider is behind llm.