<html lang="en">
<head></head>
<body>

<form id="mainForm" method="post" action="https://stackblitz.com/run" target="_self">
<input type="hidden" name="project[files][README.md]" value="# AI Agent Chat for RivetKit

Example project demonstrating AI agent integration with [RivetKit](https://rivetkit.org).

[Learn More →](https://github.com/rivet-dev/rivetkit)

[Discord](https://rivet.dev/discord) — [Documentation](https://rivetkit.org) — [Issues](https://github.com/rivet-dev/rivetkit/issues)

## Getting Started

### Prerequisites

- Node.js 18+
- OpenAI API key

### Installation

```sh
git clone https://github.com/rivet-dev/rivetkit
cd rivetkit/examples/ai-agent
npm install
```

### Development

1. Set your OpenAI API key:
```sh
export OPENAI_API_KEY=your-api-key-here
```

2. Start the development server:
```sh
npm run dev
```

3. Open your browser to `http://localhost:3000`

## License

Apache 2.0">
<input type="hidden" name="project[files][package.json]" value="{&quot;name&quot;:&quot;example-ai-agent&quot;,&quot;version&quot;:&quot;2.0.21&quot;,&quot;private&quot;:true,&quot;type&quot;:&quot;module&quot;,&quot;scripts&quot;:{&quot;dev&quot;:&quot;concurrently \&quot;npm run dev:backend\&quot; \&quot;npm run dev:frontend\&quot;&quot;,&quot;dev:backend&quot;:&quot;tsx --watch src/backend/server.ts&quot;,&quot;dev:frontend&quot;:&quot;vite&quot;,&quot;build&quot;:&quot;vite build&quot;,&quot;check-types&quot;:&quot;tsc --noEmit&quot;,&quot;test&quot;:&quot;vitest run&quot;},&quot;devDependencies&quot;:{&quot;@types/node&quot;:&quot;^22.13.9&quot;,&quot;@types/react&quot;:&quot;^18.2.0&quot;,&quot;@types/react-dom&quot;:&quot;^18.2.0&quot;,&quot;@vitejs/plugin-react&quot;:&quot;^4.2.0&quot;,&quot;concurrently&quot;:&quot;^8.2.2&quot;,&quot;rivetkit&quot;:&quot;https://pkg.pr.new/rivet-dev/rivet/rivetkit@1f30194ba7e46670304ba95e436cef024fbc089b&quot;,&quot;tsx&quot;:&quot;^3.12.7&quot;,&quot;typescript&quot;:&quot;^5.5.2&quot;,&quot;vite&quot;:&quot;^5.0.0&quot;,&quot;vitest&quot;:&quot;^3.1.1&quot;},&quot;dependencies&quot;:{&quot;@ai-sdk/openai&quot;:&quot;^0.0.66&quot;,&quot;@rivetkit/react&quot;:&quot;https://pkg.pr.new/rivet-dev/rivet/@rivetkit/react@1f30194ba7e46670304ba95e436cef024fbc089b&quot;,&quot;ai&quot;:&quot;^4.0.38&quot;,&quot;react&quot;:&quot;^18.2.0&quot;,&quot;react-dom&quot;:&quot;^18.2.0&quot;,&quot;zod&quot;:&quot;^3.25.69&quot;},&quot;stableVersion&quot;:&quot;0.8.0&quot;}">
<input type="hidden" name="project[files][tsconfig.json]" value="{
	&quot;compilerOptions&quot;: {
		&quot;target&quot;: &quot;esnext&quot;,
		&quot;lib&quot;: [&quot;esnext&quot;, &quot;dom&quot;],
		&quot;jsx&quot;: &quot;react-jsx&quot;,
		&quot;module&quot;: &quot;esnext&quot;,
		&quot;moduleResolution&quot;: &quot;bundler&quot;,
		&quot;types&quot;: [&quot;node&quot;, &quot;vite/client&quot;],
		&quot;resolveJsonModule&quot;: true,
		&quot;allowJs&quot;: true,
		&quot;checkJs&quot;: false,
		&quot;noEmit&quot;: true,
		&quot;isolatedModules&quot;: true,
		&quot;allowSyntheticDefaultImports&quot;: true,
		&quot;forceConsistentCasingInFileNames&quot;: true,
		&quot;strict&quot;: true,
		&quot;skipLibCheck&quot;: true
	},
	&quot;include&quot;: [&quot;src/**/*&quot;],
	&quot;exclude&quot;: [&quot;node_modules&quot;, &quot;dist&quot;]
}
">
<input type="hidden" name="project[files][turbo.json]" value="{
	&quot;$schema&quot;: &quot;https://turbo.build/schema.json&quot;,
	&quot;extends&quot;: [&quot;//&quot;]
}
">
<input type="hidden" name="project[files][vite.config.ts]" value="import react from &quot;@vitejs/plugin-react&quot;;
import { defineConfig } from &quot;vite&quot;;

export default defineConfig({
	plugins: [react()],
	root: &quot;src/frontend&quot;,
	server: {
		port: 3000,
	},
});
">
<input type="hidden" name="project[files][vitest.config.ts]" value="import { defineConfig } from &quot;vitest/config&quot;;

export default defineConfig({
	test: {
		include: [&quot;tests/**/*.test.ts&quot;],
		testTimeout: 30000,
	},
});
">
<input type="hidden" name="project[files][tests/ai-agent.test.ts]" value="import { setupTest } from &quot;rivetkit/test&quot;;
import { expect, test, vi } from &quot;vitest&quot;;
import { registry } from &quot;../src/backend/registry&quot;;

// Mock the AI SDK and OpenAI
vi.mock(&quot;@ai-sdk/openai&quot;, () =&gt; ({
	openai: () =&gt; &quot;mock-model&quot;,
}));

vi.mock(&quot;ai&quot;, () =&gt; ({
	generateText: vi.fn().mockImplementation(async ({ prompt }) =&gt; ({
		text: `AI response to: ${prompt}`,
	})),
	tool: vi.fn().mockImplementation(({ execute }) =&gt; ({ execute })),
}));

vi.mock(&quot;../src/backend/my-tools&quot;, () =&gt; ({
	getWeather: vi.fn().mockResolvedValue({
		location: &quot;San Francisco&quot;,
		temperature: 72,
		condition: &quot;sunny&quot;,
	}),
}));

test(&quot;AI Agent can handle basic actions without connection&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const agent = client.aiAgent.getOrCreate([&quot;test-basic&quot;]);

	// Test initial state
	const initialMessages = await agent.getMessages();
	expect(initialMessages).toEqual([]);

	// Send a message
	const userMessage = &quot;Hello, how are you?&quot;;
	const response = await agent.sendMessage(userMessage);

	// Verify response structure
	expect(response).toMatchObject({
		role: &quot;assistant&quot;,
		content: expect.stringContaining(&quot;AI response to: Hello, how are you?&quot;),
		timestamp: expect.any(Number),
	});

	// Verify messages are stored
	const messages = await agent.getMessages();
	expect(messages).toHaveLength(2);
	expect(messages[0]).toMatchObject({
		role: &quot;user&quot;,
		content: userMessage,
		timestamp: expect.any(Number),
	});
	expect(messages[1]).toEqual(response);
});

test(&quot;AI Agent maintains conversation history&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const agent = client.aiAgent.getOrCreate([&quot;test-history&quot;]);

	// Send multiple messages
	await agent.sendMessage(&quot;First message&quot;);
	await agent.sendMessage(&quot;Second message&quot;);
	await agent.sendMessage(&quot;Third message&quot;);

	const messages = await agent.getMessages();
	expect(messages).toHaveLength(6); // 3 user + 3 assistant messages

	// Verify message ordering and roles
	expect(messages[0].role).toBe(&quot;user&quot;);
	expect(messages[0].content).toBe(&quot;First message&quot;);
	expect(messages[1].role).toBe(&quot;assistant&quot;);
	expect(messages[2].role).toBe(&quot;user&quot;);
	expect(messages[2].content).toBe(&quot;Second message&quot;);
	expect(messages[3].role).toBe(&quot;assistant&quot;);
	expect(messages[4].role).toBe(&quot;user&quot;);
	expect(messages[4].content).toBe(&quot;Third message&quot;);
	expect(messages[5].role).toBe(&quot;assistant&quot;);
});

test(&quot;AI Agent handles weather tool usage&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const agent = client.aiAgent.getOrCreate([&quot;test-weather&quot;]);

	// Send a weather-related message
	const response = await agent.sendMessage(
		&quot;What&#39;s the weather in San Francisco?&quot;,
	);

	// Verify response was generated
	expect(response.role).toBe(&quot;assistant&quot;);
	expect(response.content).toContain(
		&quot;AI response to: What&#39;s the weather in San Francisco?&quot;,
	);
	expect(response.timestamp).toBeGreaterThan(0);

	// Verify message history includes both user and assistant messages
	const messages = await agent.getMessages();
	expect(messages).toHaveLength(2);
	expect(messages[0].content).toBe(&quot;What&#39;s the weather in San Francisco?&quot;);
	expect(messages[1]).toEqual(response);
});

test(&quot;AI Agent timestamps are sequential&quot;, async (ctx) =&gt; {
	const { client } = await setupTest(ctx, registry);
	const agent = client.aiAgent.getOrCreate([&quot;test-timestamps&quot;]);

	const response1 = await agent.sendMessage(&quot;First&quot;);
	const response2 = await agent.sendMessage(&quot;Second&quot;);

	expect(response2.timestamp).toBeGreaterThanOrEqual(response1.timestamp);

	const messages = await agent.getMessages();
	for (let i = 1; i &lt; messages.length; i++) {
		expect(messages[i].timestamp).toBeGreaterThanOrEqual(
			messages[i - 1].timestamp,
		);
	}
});
">
<input type="hidden" name="project[files][src/frontend/App.tsx]" value="import { createRivetKit } from &quot;@rivetkit/react&quot;;
import { useEffect, useState } from &quot;react&quot;;
import { registry } from &quot;../backend/registry&quot;;
import type { Message } from &quot;../backend/types&quot;;

const { useActor } = createRivetKit&lt;typeof registry&gt;(&quot;http://localhost:8080&quot;);

export function App() {
	const aiAgent = useActor({
		name: &quot;aiAgent&quot;,
		key: [&quot;default&quot;],
	});
	const [messages, setMessages] = useState&lt;Message[]&gt;([]);
	const [input, setInput] = useState(&quot;&quot;);
	const [isLoading, setIsLoading] = useState(false);

	useEffect(() =&gt; {
		if (aiAgent.connection) {
			aiAgent.connection.getMessages().then(setMessages);
		}
	}, [aiAgent.connection]);

	aiAgent.useEvent(&quot;messageReceived&quot;, (message: Message) =&gt; {
		setMessages((prev) =&gt; [...prev, message]);
		setIsLoading(false);
	});

	const handleSendMessage = async () =&gt; {
		if (aiAgent.connection &amp;&amp; input.trim()) {
			setIsLoading(true);

			const userMessage = { role: &quot;user&quot;, content: input, timestamp: Date.now() } as Message;
			setMessages((prev) =&gt; [...prev, userMessage]);

			await aiAgent.connection.sendMessage(input);
			setInput(&quot;&quot;);
		}
	};

	return (
		&lt;div className=&quot;ai-chat&quot;&gt;
			&lt;div className=&quot;messages&quot;&gt;
				{messages.length === 0 ? (
					&lt;div className=&quot;empty-message&quot;&gt;
						Ask the AI assistant a question to get started
					&lt;/div&gt;
				) : (
					messages.map((msg, i) =&gt; (
						&lt;div key={i} className={`message ${msg.role}`}&gt;
							&lt;div className=&quot;avatar&quot;&gt;{msg.role === &quot;user&quot; ? &quot;👤&quot; : &quot;🤖&quot;}&lt;/div&gt;
							&lt;div className=&quot;content&quot;&gt;{msg.content}&lt;/div&gt;
						&lt;/div&gt;
					))
				)}
				{isLoading &amp;&amp; (
					&lt;div className=&quot;message assistant loading&quot;&gt;
						&lt;div className=&quot;avatar&quot;&gt;🤖&lt;/div&gt;
						&lt;div className=&quot;content&quot;&gt;Thinking...&lt;/div&gt;
					&lt;/div&gt;
				)}
			&lt;/div&gt;

			&lt;div className=&quot;input-area&quot;&gt;
				&lt;input
					value={input}
					onChange={(e) =&gt; setInput(e.target.value)}
					onKeyPress={(e) =&gt; e.key === &quot;Enter&quot; &amp;&amp; handleSendMessage()}
					placeholder=&quot;Ask the AI assistant...&quot;
					disabled={isLoading}
				/&gt;
				&lt;button
					onClick={handleSendMessage}
					disabled={isLoading || !input.trim()}
				&gt;
					Send
				&lt;/button&gt;
			&lt;/div&gt;
		&lt;/div&gt;
	);
}
">
<input type="hidden" name="project[files][src/frontend/index.html]" value="&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
&lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;title&gt;AI Agent Example&lt;/title&gt;
    &lt;style&gt;
        body {
            font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, sans-serif;
            margin: 0;
            padding: 20px;
            background-color: #f5f5f5;
        }
        .ai-chat {
            max-width: 800px;
            margin: 0 auto;
            background: white;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            overflow: hidden;
        }
        .messages {
            height: 400px;
            overflow-y: auto;
            padding: 20px;
            border-bottom: 1px solid #eee;
        }
        .message {
            display: flex;
            margin-bottom: 16px;
            align-items: flex-start;
        }
        .message.user {
            justify-content: flex-end;
        }
        .message.assistant {
            justify-content: flex-start;
        }
        .avatar {
            width: 40px;
            height: 40px;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            background: #007bff;
            color: white;
            font-size: 18px;
            flex-shrink: 0;
        }
        .message.user .avatar {
            background: #28a745;
            margin-left: 10px;
        }
        .message.assistant .avatar {
            background: #007bff;
            margin-right: 10px;
        }
        .content {
            max-width: 60%;
            padding: 12px 16px;
            border-radius: 18px;
            background: #f1f1f1;
            word-wrap: break-word;
        }
        .message.user .content {
            background: #007bff;
            color: white;
        }
        .message.assistant .content {
            background: #f1f1f1;
        }
        .message.loading .content {
            background: #e9ecef;
            font-style: italic;
        }
        .empty-message {
            text-align: center;
            color: #666;
            padding: 40px;
        }
        .input-area {
            display: flex;
            padding: 20px;
            gap: 10px;
        }
        .input-area input {
            flex: 1;
            padding: 12px;
            border: 1px solid #ddd;
            border-radius: 6px;
            font-size: 16px;
        }
        .input-area button {
            padding: 12px 24px;
            background: #007bff;
            color: white;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 16px;
        }
        .input-area button:disabled {
            background: #ccc;
            cursor: not-allowed;
        }
        .input-area button:hover:not(:disabled) {
            background: #0056b3;
        }
    &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
    &lt;div id=&quot;root&quot;&gt;&lt;/div&gt;
    &lt;script type=&quot;module&quot; src=&quot;/main.tsx&quot;&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;">
<input type="hidden" name="project[files][src/frontend/main.tsx]" value="import { StrictMode } from &quot;react&quot;;
import { createRoot } from &quot;react-dom/client&quot;;
import { App } from &quot;./App&quot;;

const root = document.getElementById(&quot;root&quot;);
if (!root) throw new Error(&quot;Root element not found&quot;);

createRoot(root).render(
	&lt;StrictMode&gt;
		&lt;App /&gt;
	&lt;/StrictMode&gt;
);">
<input type="hidden" name="project[files][src/backend/my-tools.ts]" value="export async function getWeather(location: string) {
	// Mock weather API response
	return {
		location,
		temperature: Math.floor(Math.random() * 30) + 10,
		condition: [&quot;sunny&quot;, &quot;cloudy&quot;, &quot;rainy&quot;, &quot;snowy&quot;][
			Math.floor(Math.random() * 4)
		],
		humidity: Math.floor(Math.random() * 50) + 30,
	};
}
">
<input type="hidden" name="project[files][src/backend/registry.ts]" value="import { openai } from &quot;@ai-sdk/openai&quot;;
import { generateText, tool } from &quot;ai&quot;;
import { actor, setup } from &quot;rivetkit&quot;;
import { z } from &quot;zod&quot;;
import { getWeather } from &quot;./my-tools&quot;;
import type { Message } from &quot;./types&quot;;

export const aiAgent = actor({
	// Persistent state that survives restarts: https://rivet.dev/docs/actors/state
	state: {
		messages: [] as Message[],
	},

	actions: {
		// Callable functions from clients: https://rivet.dev/docs/actors/actions
		getMessages: (c) =&gt; c.state.messages,

		sendMessage: async (c, userMessage: string) =&gt; {
			const userMsg: Message = {
				role: &quot;user&quot;,
				content: userMessage,
				timestamp: Date.now(),
			};
			// State changes are automatically persisted
			c.state.messages.push(userMsg);

			const { text } = await generateText({
				model: openai(&quot;gpt-4o-mini&quot;),
				prompt: userMessage,
				messages: c.state.messages,
				tools: {
					weather: tool({
						description: &quot;Get the weather in a location&quot;,
						parameters: z.object({
							location: z
								.string()
								.describe(
									&quot;The location to get the weather for&quot;,
								),
						}),
						execute: async ({ location }) =&gt; {
							return await getWeather(location);
						},
					}),
				},
			});

			const assistantMsg: Message = {
				role: &quot;assistant&quot;,
				content: text,
				timestamp: Date.now(),
			};
			c.state.messages.push(assistantMsg);

			// Send events to all connected clients: https://rivet.dev/docs/actors/events
			c.broadcast(&quot;messageReceived&quot;, assistantMsg);

			return assistantMsg;
		},
	},
});

// Register actors for use: https://rivet.dev/docs/setup
export const registry = setup({
	use: { aiAgent },
});
">
<input type="hidden" name="project[files][src/backend/server.ts]" value="import { registry } from &quot;./registry&quot;;

registry.start();
">
<input type="hidden" name="project[files][src/backend/types.ts]" value="export type Message = {
	role: &quot;user&quot; | &quot;assistant&quot;;
	content: string;
	timestamp: number;
};
">
<input type="hidden" name="project[description]" value="generated by https://pkg.pr.new">
<input type="hidden" name="project[template]" value="node">
<input type="hidden" name="project[title]" value="example-ai-agent">
</form>
<script>document.getElementById("mainForm").submit();</script>

</body></html>