Skip to main content

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:
PatternHow it worksBest for
Ephemeral TokensBackend creates a short-lived token, frontend streams directly from Erdo APILower latency, simpler backend
Proxy StreamingBackend proxies the SSE streamStrict CSP requirements, custom logging/rate limiting
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();
}

Rendering Tool Results

// 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:
  1. 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.
  2. 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);

Building a Threads Sidebar

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;

Meta Tag (Fallback)

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;"
/>
If you’re proxying Erdo API requests through your own backend, you don’t need to add api.erdo.ai to your CSP—just ensure your proxy endpoint is allowed.