Multi-Modal Agent

Learn how to build a multi-modal agent that can process images and PDFs with the AI TOOLKIT.

9 min readmulti-modalagentimagespdfvisionnextView source

In this guide, you will build a multi-modal agent capable of understanding both images and PDFs. Multi-modal refers to the ability of the agent to understand and generate responses in multiple formats. In this guide, we'll focus on images and PDFs - two common document types that modern language models can process natively. For a complete list of providers and their multi-modal capabilities, visit the providers documentation. We'll build this agent using OpenAI's GPT-4o, but the same code works seamlessly with other providers - you can switch between them by changing just one line of code.

Run it locally

$ npm install ai

Prerequisites

To follow this quickstart, you'll need:

- Node.js 18+ and pnpm installed on your local development machine.

- A Vercel AI Gateway API key.

If you haven't obtained your Vercel AI Gateway API key, you can do so by signing up on the Vercel website.

Create Your Application

Start by creating a new Next.js application. This command will create a new directory named multi-modal-agent and set up a basic Next.js application inside it.

<div className="mb-4">

<Note>

Be sure to select yes when prompted to use the App Router. If you are

looking for the Next.js Pages Router quickstart guide, you can find it

here.

</Note>

</div>

<Snippet text="pnpm create next-app@latest multi-modal-agent" />

Navigate to the newly created directory:

<Snippet text="cd multi-modal-agent" />

### Install dependencies

Install ai and @ai-toolkit/react, the AI TOOLKIT package and the AI TOOLKIT's React package respectively.

<Note>

The AI TOOLKIT is designed to be a unified interface to interact with any large

language model. This means that you can change model and providers with just

one line of code! Learn more about available providers and

building custom providers

in the providers section.

</Note>

<div className="my-4">

<Tabs items={['pnpm', 'npm', 'yarn', 'bun']}>

<Tab>

<Snippet text="pnpm add ai @ai-toolkit/react" dark />

</Tab>

<Tab>

<Snippet text="npm install ai @ai-toolkit/react" dark />

</Tab>

<Tab>

<Snippet text="yarn add ai @ai-toolkit/react" dark />

</Tab>

<Tab>

<Snippet text="bun add ai @ai-toolkit/react" dark />

</Tab>

</Tabs>

</div>

### Configure your Vercel AI Gateway API key

Create a .env.local file in your project root and add your Vercel AI Gateway API key. This key authenticates your application with Vercel AI Gateway.

<Snippet text="touch .env.local" />

Edit the .env.local file:

AI_GATEWAY_API_KEY=your_api_key_here

Replace your_api_key_here with your actual Vercel AI Gateway API key.

<Note className="mb-4">

The AI TOOLKIT's Vercel AI Gateway Provider is the default global provider, so

you can access models using a simple string in the model configuration. If you

prefer to use a specific provider like OpenAI directly, see the [provider

management](/docs/ai-toolkit-core/provider-management) documentation.

</Note>

.env.local
env
  1. 1AI_GATEWAY_API_KEY=your_api_key_here

Implementation Plan

To build a multi-modal agent, you will need to:

- Create a Route Handler to handle incoming chat messages and generate responses.

- Wire up the UI to display chat messages, provide a user input, and handle submitting new messages.

- Add the ability to upload images and PDFs and attach them alongside the chat messages.

Create a Route Handler

Create a route handler, app/api/chat/route.ts and add the following code:

import { streamText, convertToModelMessages, type UIMessage } from 'ai-toolkit';

// Allow streaming responses up to 30 seconds

export const maxDuration = 30;

export async function POST(req: Request) {

const { messages }: { messages: UIMessage[] } = await req.json();

const result = streamText({

model: 'openai/gpt-4o',

messages: await convertToModelMessages(messages),

});

return result.toUIMessageStreamResponse();

}

Let's take a look at what is happening in this code:

1. Define an asynchronous POST request handler and extract messages from the body of the request. The messages variable contains a history of the conversation between you and the agent and provides the agent with the necessary context to make the next generation.

2. Convert the UI messages to model messages using convertToModelMessages, which transforms the UI-focused message format to the format expected by the language model.

3. Call `streamText`, which is imported from the ai package. This function accepts a configuration object that contains a model provider and messages (converted in step 2). You can pass additional settings to further customize the model's behavior.

4. The streamText function returns a `StreamTextResult`. This result object contains the `toUIMessageStreamResponse` function which converts the result to a streamed response object.

5. Finally, return the result to the client to stream the response.

This Route Handler creates a POST request endpoint at /api/chat.

app/api/chat/route.ts
tsx
  1. 1import { streamText, convertToModelMessages, type UIMessage } from 'ai-toolkit';
  2. 2// Allow streaming responses up to 30 seconds
  3. 3export const maxDuration = 30;
  4. 4export async function POST(req: Request) {
  5. 5 const { messages }: { messages: UIMessage[] } = await req.json();
  6. 6 const result = streamText({
  7. 7 model: 'openai/gpt-4o',
  8. 8 messages: await convertToModelMessages(messages),
  9. 9 });
  10. 10 return result.toUIMessageStreamResponse();
  11. 11}

Wire up the UI

Now that you have a Route Handler that can query a large language model (LLM), it's time to setup your frontend. AI TOOLKIT UI abstracts the complexity of a chat interface into one hook, `useChat`.

Update your root page (app/page.tsx) with the following code to show a list of chat messages and provide a user message input:

'use client';

import { useChat } from '@ai-toolkit/react';

import { DefaultChatTransport } from 'ai-toolkit';

import { useState } from 'react';

export default function Chat() {

const [input, setInput] = useState('');

const { messages, sendMessage } = useChat({

transport: new DefaultChatTransport({

api: '/api/chat',

}),

});

return (

<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">

{messages.map(m => (

<div key={m.id} className="whitespace-pre-wrap">

{m.role === 'user' ? 'User: ' : 'AI: '}

{m.parts.map((part, index) => {

if (part.type === 'text') {

return <span key={${m.id}-text-${index}}>{part.text}</span>;

}

return null;

})}

</div>

))}

<form

onSubmit={async event => {

event.preventDefault();

sendMessage({

role: 'user',

parts: [{ type: 'text', text: input }],

});

setInput('');

}}

className="fixed bottom-0 w-full max-w-md mb-8 border border-gray-300 rounded shadow-xl"

>

<input

className="w-full p-2"

value={input}

placeholder="Say something..."

onChange={e => setInput(e.target.value)}

/>

</form>

</div>

);

}

<Note>

Make sure you add the "use client" directive to the top of your file. This

allows you to add interactivity with JavaScript.

</Note>

This page utilizes the useChat hook, configured with DefaultChatTransport to specify the API endpoint. The useChat hook provides multiple utility functions and state variables:

- messages - the current chat messages (an array of objects with id, role, and parts properties).

- sendMessage - function to send a new message to the AI.

- Each message contains a parts array that can include text, images, PDFs, and other content types.

- Files are converted to data URLs before being sent to maintain compatibility across different environments.

app/page.tsx
tsx
  1. 1'use client';
  2. 2import { useChat } from '@ai-toolkit/react';
  3. 3import { DefaultChatTransport } from 'ai-toolkit';
  4. 4import { useState } from 'react';
  5. 5export default function Chat() {
  6. 6 const [input, setInput] = useState('');
  7. 7 const { messages, sendMessage } = useChat({
  8. 8 transport: new DefaultChatTransport({
  9. 9 api: '/api/chat',
  10. 10 }),
  11. 11 });
  12. 12 return (
  13. 13 <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
  14. 14 {messages.map(m => (
  15. 15 <div key={m.id} className="whitespace-pre-wrap">
  16. 16 {m.role === 'user' ? 'User: ' : 'AI: '}
  17. 17 {m.parts.map((part, index) => {
  18. 18 if (part.type === 'text') {
  19. 19 return <span key={`${m.id}-text-${index}`}>{part.text}</span>;
  20. 20 }
  21. 21 return null;
  22. 22 })}
  23. 23 </div>
  24. 24 ))}
  25. 25 <form
  26. 26 onSubmit={async event => {
  27. 27 event.preventDefault();
  28. 28 sendMessage({
  29. 29 role: 'user',
  30. 30 parts: [{ type: 'text', text: input }],
  31. 31 });
  32. 32 setInput('');
  33. 33 }}
  34. 34 className="fixed bottom-0 w-full max-w-md mb-8 border border-gray-300 rounded shadow-xl"
  35. 35 >
  36. 36 <input
  37. 37 className="w-full p-2"
  38. 38 value={input}
  39. 39 placeholder="Say something..."
  40. 40 onChange={e => setInput(e.target.value)}
  41. 41 />
  42. 42 </form>
  43. 43 </div>
  44. 44 );
  45. 45}

Add File Upload

To make your agent multi-modal, let's add the ability to upload and send both images and PDFs to the model. In v5, files are sent as part of the message's parts array. Files are converted to data URLs using the FileReader API before being sent to the server.

Update your root page (app/page.tsx) with the following code:

'use client';

import { useChat } from '@ai-toolkit/react';

import { DefaultChatTransport } from 'ai-toolkit';

import { useRef, useState } from 'react';

import Image from 'next/image';

async function convertFilesToDataURLs(files: FileList) {

return Promise.all(

Array.from(files).map(

file =>

new Promise<{

type: 'file';

mediaType: string;

url: string;

}>((resolve, reject) => {

const reader = new FileReader();

reader.onload = () => {

resolve({

type: 'file',

mediaType: file.type,

url: reader.result as string,

});

};

reader.onerror = reject;

reader.readAsDataURL(file);

}),

),

);

}

export default function Chat() {

const [input, setInput] = useState('');

const [files, setFiles] = useState<FileList | undefined>(undefined);

const fileInputRef = useRef<HTMLInputElement>(null);

const { messages, sendMessage } = useChat({

transport: new DefaultChatTransport({

api: '/api/chat',

}),

});

return (

<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">

{messages.map(m => (

<div key={m.id} className="whitespace-pre-wrap">

{m.role === 'user' ? 'User: ' : 'AI: '}

{m.parts.map((part, index) => {

if (part.type === 'text') {

return <span key={${m.id}-text-${index}}>{part.text}</span>;

}

if (part.type === 'file' && part.mediaType?.startsWith('image/')) {

return (

<Image

key={${m.id}-image-${index}}

src={part.url}

width={500}

height={500}

alt={attachment-${index}}

/>

);

}

if (part.type === 'file' && part.mediaType === 'application/pdf') {

return (

<iframe

key={${m.id}-pdf-${index}}

src={part.url}

width={500}

height={600}

title={pdf-${index}}

/>

);

}

return null;

})}

</div>

))}

<form

className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl space-y-2"

onSubmit={async event => {

event.preventDefault();

const fileParts =

files && files.length > 0

? await convertFilesToDataURLs(files)

: [];

sendMessage({

role: 'user',

parts: [{ type: 'text', text: input }, ...fileParts],

});

setInput('');

setFiles(undefined);

if (fileInputRef.current) {

fileInputRef.current.value = '';

}

}}

>

<input

type="file"

accept="image/*,application/pdf"

className=""

onChange={event => {

if (event.target.files) {

setFiles(event.target.files);

}

}}

multiple

ref={fileInputRef}

/>

<input

className="w-full p-2"

value={input}

placeholder="Say something..."

onChange={e => setInput(e.target.value)}

/>

</form>

</div>

);

}

In this code, you:

1. Add a helper function convertFilesToDataURLs to convert file uploads to data URLs.

1. Create state to hold the input text, files, and a ref to the file input field.

1. Configure useChat with DefaultChatTransport to specify the API endpoint.

1. Display messages using the parts array structure, rendering text, images, and PDFs appropriately.

1. Update the onSubmit function to send messages with the sendMessage function, including both text and file parts.

1. Add a file input field to the form, including an onChange handler to handle updating the files state.

app/page.tsx
tsx
  1. 1'use client';
  2. 2import { useChat } from '@ai-toolkit/react';
  3. 3import { DefaultChatTransport } from 'ai-toolkit';
  4. 4import { useRef, useState } from 'react';
  5. 5import Image from 'next/image';
  6. 6async function convertFilesToDataURLs(files: FileList) {
  7. 7 return Promise.all(
  8. 8 Array.from(files).map(
  9. 9 file =>
  10. 10 new Promise<{
  11. 11 type: 'file';
  12. 12 mediaType: string;
  13. 13 url: string;
  14. 14 }>((resolve, reject) => {
  15. 15 const reader = new FileReader();
  16. 16 reader.onload = () => {
  17. 17 resolve({
  18. 18 type: 'file',
  19. 19 mediaType: file.type,
  20. 20 url: reader.result as string,
  21. 21 });
  22. 22 };
  23. 23 reader.onerror = reject;
  24. 24 reader.readAsDataURL(file);
  25. 25 }),
  26. 26 ),
  27. 27 );
  28. 28}
  29. 29export default function Chat() {
  30. 30 const [input, setInput] = useState('');
  31. 31 const [files, setFiles] = useState<FileList | undefined>(undefined);
  32. 32 const fileInputRef = useRef<HTMLInputElement>(null);
  33. 33 const { messages, sendMessage } = useChat({
  34. 34 transport: new DefaultChatTransport({
  35. 35 api: '/api/chat',
  36. 36 }),
  37. 37 });
  38. 38 return (
  39. 39 <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
  40. 40 {messages.map(m => (
  41. 41 <div key={m.id} className="whitespace-pre-wrap">
  42. 42 {m.role === 'user' ? 'User: ' : 'AI: '}
  43. 43 {m.parts.map((part, index) => {
  44. 44 if (part.type === 'text') {
  45. 45 return <span key={`${m.id}-text-${index}`}>{part.text}</span>;
  46. 46 }
  47. 47 if (part.type === 'file' && part.mediaType?.startsWith('image/')) {
  48. 48 return (
  49. 49 <Image
  50. 50 key={`${m.id}-image-${index}`}
  51. 51 src={part.url}
  52. 52 width={500}
  53. 53 height={500}
  54. 54 alt={`attachment-${index}`}
  55. 55 />
  56. 56 );
  57. 57 }
  58. 58 if (part.type === 'file' && part.mediaType === 'application/pdf') {
  59. 59 return (
  60. 60 <iframe
  61. 61 key={`${m.id}-pdf-${index}`}
  62. 62 src={part.url}
  63. 63 width={500}
  64. 64 height={600}
  65. 65 title={`pdf-${index}`}
  66. 66 />
  67. 67 );
  68. 68 }
  69. 69 return null;
  70. 70 })}
  71. 71 </div>
  72. 72 ))}
  73. 73 <form
  74. 74 className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl space-y-2"
  75. 75 onSubmit={async event => {
  76. 76 event.preventDefault();
  77. 77 const fileParts =
  78. 78 files && files.length > 0
  79. 79 ? await convertFilesToDataURLs(files)
  80. 80 : [];
  81. 81 sendMessage({
  82. 82 role: 'user',
  83. 83 parts: [{ type: 'text', text: input }, ...fileParts],
  84. 84 });
  85. 85 setInput('');
  86. 86 setFiles(undefined);
  87. 87 if (fileInputRef.current) {
  88. 88 fileInputRef.current.value = '';
  89. 89 }
  90. 90 }}
  91. 91 >
  92. 92 <input
  93. 93 type="file"
  94. 94 accept="image/*,application/pdf"
  95. 95 className=""
  96. 96 onChange={event => {
  97. 97 if (event.target.files) {
  98. 98 setFiles(event.target.files);
  99. 99 }
  100. 100 }}
  101. 101 multiple
  102. 102 ref={fileInputRef}
  103. 103 />
  104. 104 <input
  105. 105 className="w-full p-2"
  106. 106 value={input}
  107. 107 placeholder="Say something..."
  108. 108 onChange={e => setInput(e.target.value)}
  109. 109 />
  110. 110 </form>
  111. 111 </div>
  112. 112 );
  113. 113}

Running Your Application

With that, you have built everything you need for your multi-modal agent! To start your application, use the command:

<Snippet text="pnpm run dev" />

Head to your browser and open http://localhost:3000. You should see an input field and a button to upload files.

Try uploading an image or PDF and asking the model questions about it. Watch as the model's response is streamed back to you!

Using Other Providers

With the AI TOOLKIT's unified provider interface you can easily switch to other providers that support multi-modal capabilities:

// Using Anthropic

const result = streamText({

model: 'anthropic/claude-sonnet-4-20250514',

messages: await convertToModelMessages(messages),

});

// Using Google

const result = streamText({

model: 'google/gemini-2.5-flash',

messages: await convertToModelMessages(messages),

});

Install the provider package (@ai-toolkit/anthropic or @ai-toolkit/google) and update your API keys in .env.local. The rest of your code remains the same.

<Note>

Different providers may have varying file size limits and performance

characteristics. Check the [provider

documentation](/providers/ai-toolkit-providers) for specific details.

</Note>

app/api/chat/route.ts
tsx
  1. 1// Using Anthropic
  2. 2const result = streamText({
  3. 3 model: 'anthropic/claude-sonnet-4-20250514',
  4. 4 messages: await convertToModelMessages(messages),
  5. 5});
  6. 6// Using Google
  7. 7const result = streamText({
  8. 8 model: 'google/gemini-2.5-flash',
  9. 9 messages: await convertToModelMessages(messages),
  10. 10});

Where to Next?

You've built a multi-modal AI agent using the AI TOOLKIT! Experiment and extend the functionality of this application further by exploring tool calling.