Use thread state to keep conversation history across multiple requests. Thread state expires after 1 hour of inactivity, which fits short-lived chat sessions.
The Pattern
Store each turn in ctx.thread.state, then pass the saved messages back to the model on the next request. Each browser session gets its own thread, so conversations stay separate.
typescriptsrc/agent/chat/agent.ts
import { createAgent } from '@agentuity/runtime';
import { streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { s } from '@agentuity/schema';
interface Message {
role: 'user' | 'assistant';
content: string;
}
const agent = createAgent('Chat Agent', {
description: 'Conversational agent with memory',
schema: {
input: s.object({
message: s.string(),
}),
stream: true,
},
handler: async (ctx, input) => {
// Load this thread's conversation history
const messages = (await ctx.thread.state.get<Message[]>('messages')) || [];
// Include the new user turn before calling the model
messages.push({ role: 'user', content: input.message });
const { textStream, text } = streamText({
model: anthropic('claude-sonnet-4-6'),
system: 'You are a helpful assistant. Be concise but friendly.',
messages,
});
// Save the assistant turn after streaming completes
ctx.waitUntil(async () => {
const fullResponse = await text;
messages.push({ role: 'assistant', content: fullResponse });
await ctx.thread.state.set('messages', messages);
ctx.logger.info('Conversation updated', {
messageCount: messages.length,
threadId: ctx.thread.id,
});
});
return textStream;
},
});
export default agent;Route Example
typescriptsrc/api/chat/route.ts
import { Hono } from 'hono';
import type { Env } from '@agentuity/runtime';
import chatAgent from '@agent/chat/agent';
const router = new Hono<Env>();
router.post('/', chatAgent.validator(), async (c) => {
const { message } = c.req.valid('json');
return c.body(await chatAgent.run({ message }));
});
router.delete('/', async (c) => {
await c.var.thread.destroy();
return c.json({ reset: true });
});
export default router;Frontend
A simple chat interface that displays streaming responses:
tsxsrc/web/App.tsx
import { useState, useRef, useEffect } from 'react';
interface Message {
role: 'user' | 'assistant';
content: string;
}
export function App() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const sendMessage = async () => {
if (!input.trim() || isStreaming) return;
const userMessage = input.trim();
setInput('');
setMessages((prev) => [...prev, { role: 'user', content: userMessage }]);
setIsStreaming(true);
// Reserve a bubble for the streaming assistant response
setMessages((prev) => [...prev, { role: 'assistant', content: '' }]);
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: userMessage }),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
setMessages((prev) => {
const updated = [...prev];
const lastMessage = updated.at(-1);
if (lastMessage) {
lastMessage.content += chunk;
}
return updated;
});
}
setIsStreaming(false);
};
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '1rem' }}>
<div style={{ height: '400px', overflowY: 'auto', marginBottom: '1rem' }}>
{messages.map((msg, i) => (
<div
key={i}
style={{
padding: '0.75rem',
margin: '0.5rem 0',
borderRadius: '8px',
background: msg.role === 'user' ? '#e3f2fd' : '#f5f5f5',
marginLeft: msg.role === 'user' ? '20%' : '0',
marginRight: msg.role === 'assistant' ? '20%' : '0',
}}
>
{msg.content || '...'}
</div>
))}
<div ref={messagesEndRef} />
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
placeholder="Type a message..."
disabled={isStreaming}
style={{ flex: 1, padding: '0.75rem' }}
/>
<button onClick={sendMessage} disabled={isStreaming || !input.trim()}>
{isStreaming ? '...' : 'Send'}
</button>
</div>
</div>
);
}The frontend reads the streaming response chunk by chunk and updates the UI as text arrives.
Key Points
- Thread state (
ctx.thread.state) expires after 1 hour of inactivity - Async API: All thread state methods are async (
await ctx.thread.state.get()) - Messages array stores the full conversation history
waitUntilsaves the response after streaming completes- Thread ID (
ctx.thread.id) identifies the conversation
Simpler with push()
For append-only patterns like chat history, use push() with maxRecords for automatic sliding window behavior:
await ctx.thread.state.push('messages', newMessage, 100); // Keep last 100See Also
- State Management for all state scopes
- Streaming Responses for streaming patterns