Integration Patterns
This guide covers common patterns for integrating Erdo into different application architectures.
Next.js App Router
Server Component
Fetch data on the server:
// app/analysis/page.tsx
import { ErdoClient } from '@erdoai/server';
export default async function AnalysisPage() {
const client = new ErdoClient({
authToken: process.env.ERDO_AUTH_TOKEN,
});
const result = await client.invoke('data-analyst', {
messages: [{ role: 'user', content: 'Generate a summary report' }],
});
return (
<div>
{result.result?.output?.content?.map((item, i) => (
<div key={i}>{JSON.stringify(item)}</div>
))}
</div>
);
}
Client Component with Streaming
Never expose your API key to the browser. Choose one of two secure streaming patterns below.
There are two ways to stream Erdo results to your frontend:
| Pattern | How it works | Best for |
|---|
| Ephemeral Tokens | Backend creates a short-lived token, frontend streams directly from Erdo API | Lower latency, simpler backend |
| Proxy Streaming | Backend proxies the SSE stream | Strict CSP requirements, custom logging/rate limiting |
Pattern 1: Ephemeral Tokens (Recommended)
Your backend creates a scoped, short-lived token. The frontend uses it to invoke directly.
// app/api/authorize/route.ts (SERVER)
import { ErdoClient } from '@erdoai/server';
const client = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN });
export async function POST(request: Request) {
const { botId } = await request.json();
// Add your own RBAC logic here
// if (!user.canAccess(botId)) return Response.json({ error: 'Forbidden' }, { status: 403 });
const { token, tokenId, expiresAt } = await client.createToken({
botKeys: [botKey], // Bot keys (e.g., "my-org.data-analyst")
expiresInSeconds: 3600, // 1 hour
});
return Response.json({ token, tokenId, expiresAt });
}
// app/chat/page.tsx (CLIENT)
'use client';
import { useState, useRef, useCallback, useMemo } from 'react';
import { ErdoClient } from '@erdoai/server';
import { ErdoProvider, useThread, Content } from '@erdoai/ui';
function ChatInterface() {
const [query, setQuery] = useState('');
const { activeMessages, isStreaming, sendMessage } = useThread({
botKey: 'data-analyst',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await sendMessage(query);
setQuery('');
};
const contents = activeMessages.flatMap(msg => msg.contents || []);
return (
<div>
<form onSubmit={handleSubmit}>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<button disabled={isStreaming}>{isStreaming ? 'Analyzing...' : 'Send'}</button>
</form>
{contents.map((item, i) => <Content key={item.id || i} content={item} />)}
</div>
);
}
export default function ChatPage() {
const [client, setClient] = useState<ErdoClient | null>(null);
const tokenRef = useRef<{ token: string; expiresAt: Date; endpoint: string } | null>(null);
const authenticate = useCallback(async () => {
if (tokenRef.current && new Date(tokenRef.current.expiresAt) > new Date()) {
return;
}
const res = await fetch('/api/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ botKey: 'my-org.data-analyst' }), // Bot key
});
const { token, expiresAt, endpoint } = await res.json();
tokenRef.current = { token, expiresAt: new Date(expiresAt), endpoint };
setClient(new ErdoClient({ endpoint, token }));
}, []);
const config = useMemo(() => ({
baseUrl: tokenRef.current?.endpoint || '',
client: client || undefined,
}), [client]);
if (!client) {
return <button onClick={authenticate}>Connect</button>;
}
return (
<ErdoProvider config={config}>
<ChatInterface />
</ErdoProvider>
);
}
Pattern 2: Proxy Streaming
Your backend proxies all requests. The API key never leaves your server.
// app/api/threads/route.ts (SERVER - create thread)
import { ErdoClient } from '@erdoai/server';
const client = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN });
export async function POST() {
const thread = await client.createThread();
return Response.json(thread);
}
// app/api/threads/[threadId]/message/route.ts (SERVER - send message)
import { ErdoClient } from '@erdoai/server';
const client = new ErdoClient({ authToken: process.env.ERDO_AUTH_TOKEN });
export async function POST(
request: Request,
{ params }: { params: Promise<{ threadId: string }> }
) {
const { threadId } = await params;
const { content, botKey } = await request.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
for await (const event of client.sendMessage(threadId, { content, botKey })) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
}
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
});
}
// app/chat/page.tsx (CLIENT)
'use client';
import { useState, useCallback, useRef } from 'react';
import { ErdoProvider, Content, handleSSEEvent } from '@erdoai/ui';
import type { SSEEvent } from '@erdoai/types';
function ChatInterface() {
const [query, setQuery] = useState('');
const [threadId, setThreadId] = useState<string | null>(null);
const [activeMessages, setActiveMessages] = useState<any[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsStreaming(true);
// Create thread if needed
let currentThreadId = threadId;
if (!currentThreadId) {
const res = await fetch('/api/threads', { method: 'POST' });
const thread = await res.json();
currentThreadId = thread.id;
setThreadId(thread.id);
}
// Send message via proxy
const response = await fetch(`/api/threads/${currentThreadId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: query, botKey: 'data-analyst' }),
});
// Process SSE stream
const reader = response.body?.getReader();
const decoder = new TextDecoder();
const messagesByID: Record<string, any> = {};
while (reader) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder.decode(value).split('\n');
for (const line of lines) {
if (line.startsWith('data: ') && line.slice(6) !== '[DONE]') {
const event: SSEEvent = JSON.parse(line.slice(6));
handleSSEEvent(event.type || '', event, [], currentThreadId, messagesByID);
setActiveMessages(Object.values(messagesByID));
}
}
}
setIsStreaming(false);
};
const contents = activeMessages.flatMap(msg => msg.contents || []);
return (
<div>
<form onSubmit={handleSubmit}>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<button disabled={isStreaming}>{isStreaming ? 'Analyzing...' : 'Send'}</button>
</form>
{contents.map((item, i) => <Content key={item.id || i} content={item} />)}
</div>
);
}
export default function ChatPage() {
return (
<ErdoProvider config={{ baseUrl: '' }}>
<ChatInterface />
</ErdoProvider>
);
}
Vercel AI SDK
Use Erdo as a tool in your AI chat application with Vercel AI SDK 6:
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText, tool } from 'ai';
import { z } from 'zod';
import { ErdoClient } from '@erdoai/server';
const erdoClient = new ErdoClient({
authToken: process.env.ERDO_AUTH_TOKEN,
});
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
messages,
tools: {
analyzeData: tool({
description: 'Analyze data and create visualizations using Erdo',
parameters: z.object({
query: z.string().describe('The data analysis question'),
}),
execute: async ({ query }) => {
const result = await erdoClient.invoke('data-analyst', {
messages: [{ role: 'user', content: query }],
});
return result;
},
}),
},
});
return result.toUIMessageStreamResponse();
}
// components/chat.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { Content } from '@erdoai/ui';
export function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat();
return (
<div>
{messages.map((m) => (
<div key={m.id}>
{m.role === 'user' ? 'User: ' : 'AI: '}
{/* Render text parts */}
{m.parts
?.filter((part) => part.type === 'text')
.map((part, i) => (
<span key={i}>{part.text}</span>
))}
{/* Render Erdo tool results */}
{m.parts
?.filter((part) => part.type === 'tool-invocation' && part.toolInvocation.toolName === 'analyzeData')
.map((part, i) => {
const toolResult = part.toolInvocation.result;
if (!toolResult) return null;
return (
<div key={i}>
{toolResult.result?.output?.content?.map((item: unknown, j: number) => (
<Content key={j} content={item} />
))}
</div>
);
})}
</div>
))}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
<button type="submit">Send</button>
</form>
</div>
);
}
Express.js / Node.js
REST API Endpoint
// server.ts
import express from 'express';
import { ErdoClient } from '@erdoai/server';
const app = express();
const client = new ErdoClient({
authToken: process.env.ERDO_AUTH_TOKEN,
});
app.use(express.json());
app.post('/api/analyze', async (req, res) => {
const { query } = req.body;
try {
const result = await client.invoke('data-analyst', {
messages: [{ role: 'user', content: query }],
});
res.json(result);
} catch (error) {
res.status(500).json({ error: 'Analysis failed' });
}
});
app.listen(3000);
SSE Streaming Endpoint
app.get('/api/analyze/stream', async (req, res) => {
const query = req.query.q as string;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
for await (const event of client.invokeStream('data-analyst', {
messages: [{ role: 'user', content: query }],
})) {
res.write(`data: ${JSON.stringify(event)}\n\n`);
}
} catch (error) {
res.write(`data: ${JSON.stringify({ type: 'error', payload: error })}\n\n`);
}
res.end();
});
Proxying Through Your Backend
For B2B applications, you may want to proxy Erdo API requests through your own backend rather than having the client call api.erdo.ai directly. This approach:
- Keeps your Erdo API key server-side only
- Avoids CSP configuration for
api.erdo.ai
- Allows you to add custom authentication, logging, or rate limiting
Option 1: Custom Endpoint
Point the SDK to your own API endpoint:
// Server-side client
const client = new ErdoClient({
authToken: process.env.ERDO_AUTH_TOKEN,
endpoint: 'https://your-backend.com/api/erdo', // Your proxy
});
// Or via environment variable
// ERDO_ENDPOINT=https://your-backend.com/api/erdo
// UI provider
<ErdoProvider
config={{
baseUrl: 'https://your-backend.com/api/erdo',
authToken: userSessionToken, // Your app's auth, not Erdo key
}}
>
{children}
</ErdoProvider>
Your backend then forwards requests to api.erdo.ai:
// Your backend proxy endpoint
app.all('/api/erdo/*', async (req, res) => {
const erdoPath = req.path.replace('/api/erdo', '');
const response = await fetch(`https://api.erdo.ai${erdoPath}`, {
method: req.method,
headers: {
'Authorization': `Bearer ${process.env.ERDO_AUTH_TOKEN}`,
'Content-Type': 'application/json',
},
body: req.method !== 'GET' ? JSON.stringify(req.body) : undefined,
});
// Forward the response (including SSE streams)
res.status(response.status);
response.body?.pipeTo(new WritableStream({
write: (chunk) => res.write(chunk),
close: () => res.end(),
}));
});
Option 2: Custom Data Fetcher
For more control over how data is fetched (e.g., using a typed API client), provide a custom dataFetcher:
// lib/erdo-fetcher.ts - Create ONCE outside components
import { DataFetcher } from '@erdoai/ui';
export const erdoDataFetcher: DataFetcher = {
fetchDatasetContents: async (slug, invocationId) => {
// Use your own API client
const res = await fetch(`/api/datasets/${slug}?invocationId=${invocationId}`);
return res.json();
},
};
// providers/erdo-provider.tsx
import { ErdoProvider } from '@erdoai/ui';
import { erdoDataFetcher } from '../lib/erdo-fetcher';
export function AppProvider({ children }) {
return (
<ErdoProvider
config={{
baseUrl: '/api',
dataFetcher: erdoDataFetcher,
}}
>
{children}
</ErdoProvider>
);
}
When using a custom dataFetcher, the baseUrl is still used by the useThread hook. Only fetchDatasetContents calls are overridden.
Error Handling
Client-Side Error Boundary
import { ErrorBoundary } from '@erdoai/ui';
function App() {
return (
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ChatInterface />
</ErrorBoundary>
);
}
Hook-Level Error Handling
const { error, sendMessage } = useThread({
botKey: 'data-analyst',
onError: (err) => {
// Log to error tracking service
console.error('Message failed:', err);
toast.error('Analysis failed. Please try again.');
},
});
Authentication
API Key (Server-Side)
Store your API key securely in environment variables:
# .env
ERDO_AUTH_TOKEN=your-api-key
// Only use on server-side
const client = new ErdoClient({
authToken: process.env.ERDO_AUTH_TOKEN,
});
Client-Side Authentication
Never expose API keys to the browser. Use ephemeral tokens or proxy streaming instead.
For client-side usage, you have two secure options:
-
Ephemeral Tokens: Your backend creates a short-lived, scoped token using
createToken(). The frontend uses this token to invoke directly. See Client Component with Streaming above.
-
Proxy Streaming: Your backend proxies all requests to the Erdo API. The API key never leaves your server. See Proxying Through Your Backend below.
Scoped Tokens (B2B2C)
For B2B2C applications where your customers need to expose Erdo agents and datasets to their end users, use scoped tokens. Scoped tokens are:
- Short-lived: Expire after 1-24 hours (configurable)
- Scoped: Only grant access to specific bots and datasets
- User-bound: Can be linked to your external user ID for thread management
This pattern is ideal for:
- SaaS products embedding AI agents for their customers
- Dashboards where end users should only access specific bots/datasets
- Applications requiring fine-grained, temporary access control
Creating Scoped Tokens (Backend)
Create a scoped token from your backend using createToken():
// Your backend API route
import { ErdoClient } from '@erdoai/server';
const client = new ErdoClient({
authToken: process.env.ERDO_AUTH_TOKEN,
});
app.post('/api/erdo-token', async (req, res) => {
// Create a scoped token for this user
const { token, tokenId, expiresAt } = await client.createToken({
botKeys: ['my-org.data-analyst'], // Bot keys the user can access
datasetIds: ['dataset-uuid-1'], // Datasets the user can query
externalUserId: req.user.id, // Links token to your user for thread access
expiresInSeconds: 3600, // 1 hour
});
// Return the token to your frontend
res.json({ token, tokenId, expiresAt });
});
Thread access options:
-
externalUserId (recommended): Threads created by the token holder are automatically accessible. The user can list, view, and continue their own threads across sessions.
-
threadIds: Grant access to specific pre-existing threads. Useful when you want to give a user access to threads they didn’t create (e.g., shared conversations).
// Grant access to specific threads
const { token, tokenId } = await client.createToken({
botKeys: ['my-org.data-analyst'], // Bot keys
threadIds: ['thread-uuid-1', 'thread-uuid-2'], // Pre-existing threads
expiresInSeconds: 3600,
});
Using Scoped Tokens (Frontend)
Pass the scoped token to ErdoProvider:
'use client';
import { ErdoProvider, DatasetChart } from '@erdoai/ui';
import { useEffect, useState } from 'react';
function Dashboard() {
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
// Fetch scoped token from your backend
fetch('/api/erdo-token', { method: 'POST' })
.then(res => res.json())
.then(data => setToken(data.token));
}, []);
if (!token) return <div>Loading...</div>;
return (
<ErdoProvider
config={{
baseUrl: 'https://api.erdo.ai',
token, // Use scoped token instead of authToken
}}
>
<DatasetChart
chartType="bar"
title="Sales Report"
invocationId="inv-123"
datasetSlugs={['sales-data']}
xAxis={{ key: 'date', label: 'Date' }}
yAxes={[{ key: 'revenue', label: 'Revenue' }]}
series={[{ key: 'revenue', name: 'Revenue', color: '#3b82f6', datasetSlug: 'sales-data' }]}
/>
</ErdoProvider>
);
}
Using Threads with Scoped Tokens
When a token is created with externalUserId, users can create and manage persistent conversation threads:
import { ErdoClient } from '@erdoai/server';
// Client-side: Use the scoped token
const client = new ErdoClient({
endpoint: 'https://api.erdo.ai',
token: scopedToken,
});
// Create a thread for this user
const thread = await client.createThread({ name: 'Data Analysis' });
// Send messages and stream responses
for await (const event of client.sendMessage(thread.id, {
content: 'What were our top products last quarter?',
botKey: 'my-org.data-analyst',
})) {
console.log(event.type, event.payload);
}
// List user's threads
const { threads } = await client.listThreads();
Persisting External User IDs
To enable users to access their threads across sessions, store the external user ID in your database:
// Your database schema
// users: { id, email, erdo_external_user_id }
// When user signs up or first uses Erdo
async function getOrCreateErdoUserId(userId: string) {
const user = await db.users.find(userId);
if (!user.erdo_external_user_id) {
// Generate and store a unique ID for this user
user.erdo_external_user_id = `ext-${userId}`; // Or use crypto.randomUUID()
await db.users.update(userId, { erdo_external_user_id: user.erdo_external_user_id });
}
return user.erdo_external_user_id;
}
// When creating tokens, use the stored ID
app.post('/api/erdo-token', async (req, res) => {
const externalUserId = await getOrCreateErdoUserId(req.user.id);
const { token } = await client.createToken({
botKeys: ['my-org.data-analyst'],
externalUserId, // Same ID every time = same threads
expiresInSeconds: 3600,
});
res.json({ token });
});
The external user ID is embedded in the token and never exposed to the client. Users cannot access other users’ threads.
Persisting Message History
For production applications, we recommend storing messages in your own database rather than fetching from Erdo each time. This gives you full control and avoids extra API calls:
// Save messages when streaming completes
const { streamingContents, sendMessage } = useThread({
botKey: 'my-org.data-analyst',
onFinish: async () => {
// Save to your database
await db.messages.create({
threadId,
role: 'assistant',
contents: streamingContents,
createdAt: new Date(),
});
},
});
// Load history from your database
const { data: history } = useQuery({
queryKey: ['messages', threadId],
queryFn: () => db.messages.findByThreadId(threadId),
});
Alternatively, you can fetch history from Erdo using client.getThreadMessages():
// Fetch from Erdo API (simpler, but adds latency)
const { messages } = await client.getThreadMessages(threadId);
A common pattern is to show users their previous conversations in a sidebar. Here’s how to implement this:
function ThreadsSidebar({
client,
selectedThreadId,
onSelectThread,
}: {
client: ErdoClient;
selectedThreadId: string | null;
onSelectThread: (threadId: string, messages: ThreadMessage[]) => void;
}) {
const [threads, setThreads] = useState<Thread[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Fetch threads on mount
useEffect(() => {
async function fetchThreads() {
const { threads } = await client.listThreads();
setThreads(threads);
setIsLoading(false);
}
fetchThreads();
}, [client]);
// Load messages when a thread is selected
const handleSelect = async (thread: Thread) => {
const { messages } = await client.getThreadMessages(thread.id);
onSelectThread(thread.id, messages);
};
if (isLoading) return <div>Loading...</div>;
return (
<div className="w-64 border-r">
<button onClick={() => onSelectThread(null, [])}>
+ New Chat
</button>
{threads.map((thread) => (
<button
key={thread.id}
onClick={() => handleSelect(thread)}
className={selectedThreadId === thread.id ? 'bg-blue-100' : ''}
>
{thread.name || `Thread ${thread.id.slice(0, 8)}`}
</button>
))}
</div>
);
}
Then use it with your chat component:
function ChatApp() {
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [initialMessages, setInitialMessages] = useState<ThreadMessage[]>([]);
const handleSelectThread = (threadId: string | null, messages: ThreadMessage[]) => {
setSelectedThreadId(threadId);
setInitialMessages(messages);
};
return (
<div className="flex h-screen">
<ThreadsSidebar
client={client}
selectedThreadId={selectedThreadId}
onSelectThread={handleSelectThread}
/>
<ChatInterface
key={selectedThreadId || 'new'} // Force remount on thread change
threadId={selectedThreadId}
initialMessages={initialMessages}
/>
</div>
);
}
Token API Reference
CreateTokenParams:
interface CreateTokenParams {
botKeys?: string[]; // Bot keys to grant access (e.g., ["my-org.data-analyst"])
datasetIds?: string[]; // Dataset IDs the token can query
threadIds?: string[]; // Thread IDs the token can access
externalUserId?: string; // Your user ID (enables thread management)
expiresInSeconds?: number; // Token lifetime (default: 3600)
}
TokenResponse:
interface TokenResponse {
tokenId: string; // Token ID (for revocation)
token: string; // The scoped token
expiresAt: string; // ISO timestamp when token expires
}
Scoped tokens are more secure than API keys for client-side use because they:
- Expire automatically
- Only grant access to specific bots and datasets
- Can be linked to external users for personalized thread access
Content Security Policy (CSP)
If your application uses Content Security Policy headers, you’ll need to allow connections to the Erdo API for the UI components to fetch data.
Required Directives
Add the following to your CSP configuration:
connect-src 'self' https://api.erdo.ai;
This allows the @erdoai/ui components to:
- Fetch dataset contents for rendering charts and tables
- Stream agent invocation results in real-time
Next.js Configuration
// next.config.js
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
connect-src 'self' https://api.erdo.ai;
img-src 'self' data: blob:;
`.replace(/\n/g, ''),
},
];
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
];
},
};
Nginx Configuration
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://api.erdo.ai; script-src 'self'; style-src 'self' 'unsafe-inline';" always;
If you can’t configure server headers, use a meta tag:
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src 'self' https://api.erdo.ai;"
/>