← All writing

A basic LLM harness in TypeScript

A starting point for a tutor agent with the Vercel AI SDK, covering tools, saved conversations and streamed progress.

Suppose you’re building a tutor agent to help students understand their course material. To answer a question, it may need to read a lesson, search for an example, then explain what it found. The student should be able to follow that work as it happens.

The harness is the application code that orchestrates the agent’s work to complete the student’s request. It calls the model, runs the tools the model requests, and passes their results back so the agent can continue. It also saves the conversation, streams progress and handles interruptions such as tool approval or a lost connection.

The Vercel AI SDK provides the model loop and message types for this. The examples below build on those, leaving HTTP routing and database implementations to the application.

Model configuration

Start with a model and instructions for the tutor agent. This example uses GPT-6 Sol through the SDK’s OpenAI provider, with Zod for tool input schemas:

1npm install ai@7 @ai-sdk/openai@4 zod@4

With OPENAI_API_KEY set in the server environment, the first call can answer a student question:

1import { openai } from '@ai-sdk/openai';
2import { generateText } from 'ai';
3
4const model = openai('gpt-6-sol');
5const system = `You are a student tutoring agent.
6Help the student understand their course material, not do the work for them.
7Read the lesson before explaining it. Cite sources for web examples.
8Ask the student questions to verify their understanding, one at a time.`;
9
10const result = await generateText({
11 model, system,
12 prompt: 'Explain why 1/2 is equal to 2/4.',
13});

That gives you a first answer, but the model still can’t read a lesson or search the web. Those need tools.

Course tools

Give the tutor agent a way to read the lesson it’s explaining. This tool searches the student’s course and returns the relevant text:

1import { tool } from 'ai';
2import { z } from 'zod';
3
4const courseTools = {
5 readLesson: tool({
6 description: 'Find relevant lesson text in the student’s current course',
7 inputSchema: z.object({ topicSearch: z.string().max(100) }),
8 execute: ({ topicSearch }, { abortSignal }) =>
9 lessons.searchCourse(studentId, courseId, topicSearch, abortSignal),
10 }),
11};

This keeps the explanation grounded in the student’s course. The tutor agent chooses what topic to look up, while your application controls which course material it can access.

The model loop

Helping with a lesson may take a few attempts to find and use the right material. Give the tutor agent a loop so it can choose a tool, read the result and decide what to do next:

1import { stepCountIs } from 'ai';
2
3const result = await generateText({
4 model, system, messages,
5 tools: courseTools,
6 stopWhen: stepCountIs(8),
7 maxOutputTokens: 1_500,
8 abortSignal: signal,
9});
10
11if (result.finishReason !== 'stop') {
12 throw new Error(`Tutor agent stopped before completion: ${result.finishReason}`);
13}

The tutor agent can keep using tools within the eight-call limit set by stepCountIs(8). The loop ends sooner if it finishes its response or you cancel through abortSignal.

Web search

The student might understand the lesson but ask, “Where would I use this?” Add OpenAI’s web search tool to help the tutor agent find an example beyond the course:

1const tools = {
2 ...courseTools,
3 web_search: openai.tools.webSearch({}),
4};

Use tools in the loop to make both lookups available. OpenAI runs search for you, with no local execute function, and returns source URLs to cite. Pages can also contain instructions aimed at the agent, so your application still needs to enforce access checks.

Sub-agents as tools

If finding an example takes several searches, you don’t need all those results in the tutor agent’s conversation. Give that job to a sub-agent, which returns a short explanation and sources through a tool:

1const research = tool({
2 description: 'Find a sourced example to help explain a lesson',
3 inputSchema: z.object({ question: z.string() }),
4 execute: async ({ question }, { abortSignal }) => {
5 const result = await generateText({
6 model,
7 system: 'Find one relevant example. Return a paragraph and URLs.',
8 prompt: question,
9 tools: { web_search: tools.web_search },
10 stopWhen: stepCountIs(3),
11 maxOutputTokens: 800,
12 abortSignal,
13 });
14 if (result.finishReason !== 'stop') throw new Error('Research incomplete');
15 return { text: result.text, sources: result.sources };
16 },
17});
18
19const tutorTools = { ...tools, research };

The student is still making one request, even when research runs in another conversation. Use the same abortSignal and count both agents’ calls towards one budget, so cancellation and spending limits cover the whole task.

Messages and session state

Alongside the explanation, the student can see which lessons and sources the tutor agent used. The SDK’s UIMessage represents a message as it appears in the interface, with an ordered parts array for text, sources and tools still running.

You can save these UI messages and show them in the browser. When the student asks a follow-up, convertToModelMessages turns that conversation into ModelMessage[], ready for the next model call:

1import type { InferUITools, UIMessage } from 'ai';
2
3type TutorMessage = UIMessage<
4 unknown,
5 never,
6 InferUITools<typeof tutorTools>
7>;
8
9type Session = {
10 id: string;
11 messages: TutorMessage[];
12 status: 'idle' | 'running' | 'waiting' | 'completed' | 'cancelled' | 'error';
13 error?: string;
14};

Streaming and saving progress

The student shouldn’t have to wait for every tool call before seeing anything. Switch to streamText to show progress, then use readUIMessageStream to assemble text and tool updates into UIMessage snapshots you can save.

Start with the student’s new question in the saved conversation and mark the session running. The store helpers below save each update:

1import {
2 convertToModelMessages, readUIMessageStream, streamText,
3} from 'ai';
4
5const result = streamText({
6 model, system,
7 messages: await convertToModelMessages(session.messages, { tools: tutorTools }),
8 tools: tutorTools,
9 stopWhen: stepCountIs(8),
10 maxOutputTokens: 1_500,
11 abortSignal: signal,
12});
13
14const uiStream = result.toUIMessageStream({ sendSources: true });
15const updates = readUIMessageStream<TutorMessage>({
16 stream: uiStream,
17 terminateOnError: true,
18});
19
20for await (const message of updates) {
21 await store.saveMessage(session.id, message);
22}
23
24signal.throwIfAborted();
25if (await result.finishReason !== 'stop') {
26 throw new Error('Tutor agent stopped before completion');
27}
28await store.setStatus(session.id, 'completed');

Saving a partial answer lets the student return to what they’ve already read if something interrupts the request. For longer answers, batch these saves to reduce database writes, and flush the last batch before marking the request complete.

For actions the student should review first, needsApproval lets you ask permission before a tool runs.

Keeping the connection separate

A dropped connection shouldn’t make the student start over. Run the tutor agent independently of the browser connection and let the browser subscribe to saved progress through server-sent events (SSE). Reconnecting then catches up with the existing work.

Sending full snapshots is simple but repeats the conversation on every update. For longer chats, send deltas containing only new content. Save those events with IDs so a reconnect can replay what was missed. Your endpoint still checks session ownership before sending updates.

Summary

For the student, these pieces come together as a conversation they can follow and return to. The tutor agent can consult their course, research an example and help them work through the lesson. The harness keeps that work going and saves the conversation, ready for their next question.

This example is deliberately small. To make it ready for students, you’d add a fuller toolset, a chat UI and persistent storage for conversations and progress. You’d also need authentication, permission controls, spending limits and recovery for interrupted work. An evaluation process would check the tutor agent’s accuracy and teaching quality using representative student questions.