Cedar provides a flexible storage system for managing chat messages with automatic persistence and automatic thread management. By default, Cedar uses no storage - no persistence unless you configure a storage adapter.
Storage Requirements: Message persistence requires both userId and threadId to be set. Without these IDs, storage operations are skipped and messages remain in memory only.

Quick Start

Cedar works out of the box with no storage (no persistence). To enable persistence, configure a storage adapter.

Storage Options

Cedar supports three storage adapters:
  1. Local Storage (default) - Browser localStorage
  2. No Storage - Disables persistence
  3. Custom Storage - Your own implementation
Configure storage by passing the messageStorage prop to CedarCopilot:
import { CedarCopilot } from 'cedar-os';

function App() {
	return (
		<CedarCopilot
			// No storage prop needed - uses local storage by default.
			// Optionally pass in user ID
			userId='user-123'>
			<YourChatComponent />
		</CedarCopilot>
	);
}

Additional Configuration Requirements by Storage Type

Local Storage & No Storage

No additional configuration needed - these options work out of the box.

Custom Storage

Implement your own storage solution by providing a custom adapter:
interface MessageStorageBaseAdapter {
	// Required methods
	loadMessages(userId: string, threadId: string): Promise<Message[]>;
	persistMessage(
		userId: string,
		threadId: string,
		message: Message
	): Promise<Message>; // returns the saved message

	// Optional thread methods
	listThreads?(userId: string): Promise<MessageThreadMeta[]>;
	createThread?(
		userId: string,
		threadId: string,
		meta: MessageThreadMeta
	): Promise<MessageThreadMeta>; // returns new meta
	updateThread?(
		userId: string,
		threadId: string,
		meta: MessageThreadMeta
	): Promise<MessageThreadMeta>; // returns updated meta
	deleteThread?(
		userId: string,
		threadId: string
	): Promise<MessageThreadMeta | undefined>; // returns deleted meta (optional)

	// Optional message methods
	updateMessage?(
		userId: string,
		threadId: string,
		message: Message
	): Promise<Message>; // returns updated message
	deleteMessage?(
		userId: string,
		threadId: string,
		messageId: string
	): Promise<Message | undefined>; // returns deleted message (optional)
}

How Default Message Storage Works

Cedar preconfigures several storage methods that orchestrate your adapter’s functionality. Understanding these helps you work with Cedar’s storage system effectively.

Preconfigured Storage Methods

Cedar provides these methods out of the box. These methods are automatically called by Cedar at appropriate times - you don’t need to call them directly. To customize their behavior, you can override them in your implementation.

1. initializeChat({threadId?: string, userId?: string})

This method orchestrates the initial loading of threads and messages. It’s automatically called:
  • When the message storage adapter is first set
  • When userId or threadId changes in Cedar state
Here’s what it does with your adapter:
// Cedar's initializeChat flow:
1. Get userId (from params or Cedar state)
2. Get threadId (from params or Cedar state)
3. Call adapter.listThreads(userId) to load user's threads
4. If no thread is selected and threads exist:
   - Automatically select the first thread
5. If **no threads exist** and `adapter.createThread` is available:
   - A brand-new thread is created (ID `thread-{timestamp}-{rand}`)
   - The new thread metadata is persisted via `adapter.createThread`
   - Cedar sets this new ID in state
6. Use the provided threadId OR the auto-selected / newly-created threadId
7. Clear any existing messages in the UI
8. If both userId and threadId exist:
   - Call adapter.loadMessages(userId, threadId)
   - Display the loaded messages

2. persistMessageStorageMessage(message)

This method handles message persistence with intelligent thread management:
// Cedar's persistMessageStorageMessage flow:
1. Get userId from Cedar state (skip entire process if no userId)
2. Get threadId from Cedar state
3. Call adapter.persistMessage(userId, threadId, message)
4. If adapter.updateThread is available:
   - Update thread metadata (preserving original title if exists)
   - Set updatedAt to current timestamp
5. Reload and refresh the thread list via loadAndSelectThreads()

3. sendMessage() Storage Actions

When a user sends a message, Cedar performs these storage operations:
// Cedar's sendMessage storage flow:
1. Add user message to UI via addMessage()
   - addMessage() automatically calls persistMessageStorageMessage()
   - This triggers the persistence flow described above
2. When assistant responds:
   - For non-streaming: Add response via addMessage() (auto-persisted)
   - For streaming:
     - Append content to messages during stream
     - Persist all new messages after stream completes
Best practice: make your load endpoints idempotent. Implementations of listThreads or loadMessages should NOT perform any mutations such as creating threads or messages. Cedar may call these functions multiple times during normal operation; if they create data each time you could end up with duplicated threads or messages.

Automatic Behaviors

Thread & Message Loading:
  • When storage adapter is set: Adapter is configured but chat initialization happens separately
  • When user ID or thread ID changes: Triggers initializeChat which:
    • Loads all threads for the user
    • May auto-select first thread if none selected
    • Loads messages for the selected thread
  • When userId is undefined: Storage operations are skipped entirely
  • When threadId is undefined: A new UUID is generated during message persistence

User and Thread Management

User ID and thread ID are both stored as Cedar State variables and are automatically sent to the backend for Mastra and Custom backends. See Agent Backend Connection docs for more details.

Setting User ID

User ID is a Cedar State variable that can be set in two ways: Method 1: Initial Configuration (Recommended) Pass the user ID directly to CedarCopilot as a prop. This is the simplest and most common approach:
import { CedarCopilot } from 'cedar-os';

function App({ userId }: { userId: string }) {
	return (
		<CedarCopilot
			userId={userId} // Set user ID in initial configuration
			messageStorage={{
				type: 'local', // or 'none', 'custom'
			}}>
			<YourChatComponent />
		</CedarCopilot>
	);
}
When you pass userId to CedarCopilot, it automatically: - Registers it as a Cedar State variable (null values become empty strings) - Triggers initializeChat which loads threads for that user - Makes it available throughout your app via Cedar State
Method 2: Dynamic Setting via State APIs Use this when you need to change users at runtime (e.g., after login, user switching):
import { setCedarState } from 'cedar-os';

function LoginHandler() {
	const handleLogin = async (credentials) => {
		const user = await loginUser(credentials);

		// Set user ID - Cedar will load this user's threads
		setCedarState('userId', user.id);
	};

	return <LoginForm onSubmit={handleLogin} />;
}
When the user ID changes:
  1. Cedar loads threads for the new user
  2. If no thread is selected, may auto-select first thread (if listThreads is implemented)
  3. Messages are cleared and reloaded based on the user’s threads

Setting Thread ID

Thread ID is a Cedar State variable that can be set in three ways: Method 1: Initial Configuration Pass the thread ID directly to CedarCopilot as a prop. This is useful when you want to start with a specific thread:
import { CedarCopilot } from 'cedar-os';

function App({ userId, threadId }: { userId: string; threadId: string }) {
	return (
		<CedarCopilot
			userId={userId}
			threadId={threadId} // Set initial thread ID
			messageStorage={{
				type: 'local',
			}}>
			<YourChatComponent />
		</CedarCopilot>
	);
}
Like userId, threadId is registered as a Cedar State variable with null values converted to empty strings. This triggers initializeChat to load messages for the specified thread.
Method 2: Explicit Setting (User Control) Use this when users manually select threads, navigate to specific conversations, or create new threads at runtime:
import { setCedarState } from 'cedar-os';

function ConversationSwitcher() {
	const switchToThread = (threadId: string) => {
		// Cedar will save current thread and load the new one
		setCedarState('threadId', threadId);
	};

	return (
		<div>
			<button onClick={() => switchToThread('project-discussion')}>
				Project Discussion
			</button>
			<button onClick={() => switchToThread('support-ticket')}>
				Support Ticket
			</button>
		</div>
	);
}
Method 3: Automatic Selection via listThreads If you implement the listThreads method in your storage adapter, Cedar will automatically select the first available thread when no thread is currently selected. This is perfect for app initialization:
const myStorageAdapter = {
	async listThreads(userId) {
		// Return user's threads from your backend/database
		return [
			{
				id: 'thread-1',
				title: 'General Chat',
				updatedAt: '2024-01-20T10:00:00Z',
			},
			{
				id: 'thread-2',
				title: 'Project Discussion',
				updatedAt: '2024-01-19T15:30:00Z',
			},
		];
	},
	// ... other required methods
};

// When storage adapter is set or threads are loaded:
// - If threadId is null AND threads exist
// - Cedar automatically calls: setCedarState('threadId', threads[0].id)
// - In this example, 'thread-1' would be selected
When the thread ID changes (via any method), Cedar automatically:
  1. Clears the current messages from the UI
  2. Loads messages for the new thread from storage
  3. Updates the UI with the new thread’s messages

Next Steps