Chat with Conversation History — Agentuity Documentation

Chat with Conversation History

Build a chat agent that remembers previous messages using thread state

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
  • waitUntil saves the response after streaming completes
  • Thread ID (ctx.thread.id) identifies the conversation

See Also