LLM Context
LLM Context
Click the Copy Page button above to copy all documentation content for use with AI assistants like Claude, ChatGPT, or other LLMs. The complete documentation will be copied to your clipboard for easy pasting.
The frontend implementation with whatever UI components you choose is completely within your control. Our demo project is available as a reference.
Monetize your AI application with context-aware ads.
Works with ANY LLM - OpenAI, Google Gemini, Claude, or custom models
What is EarnLayer?
EarnLayer is a monetization platform that adds context-aware advertisements to your AI chat applications. Instead of charging users directly, you can earn revenue from relevant, non-intrusive ads that match your conversation topics.
How It Works
EarnLayer provides two types of ads:
1. Hyperlink Ads
Sponsored links embedded directly in AI responses via Model Context Protocol (MCP). When users ask about products or services, the AI naturally recommends sponsored options with clickable links.
Example:
User: "What are the best project management tools?"
AI: "Based on your needs, I recommend [Asana](sponsored-link),
[Monday.com](sponsored-link), or [ClickUp](sponsored-link)..."
2. Display Ads
Visual advertisements (banners, popups, videos) shown in your chat interface. These update contextually based on the conversation and can be placed in sidebars, inline, or during loading states.
Quick Start
Already have a working chat app? Perfect! Add EarnLayer in ~20 minutes with our phased approach.
Phase 1A (5 min): See hyperlink ads immediately - quick win!
Phase 1B (10 min): Complete setup - production ready!
Phase 2 (5-10 min): Add display ads - maximize revenue!
→ Start with Phase 1A
What You’ll Build
Phase 1A Result
Sponsored hyperlinks in AI responses
Immediate monetization
Phase 1B Result
Full impression tracking
Production-ready billing
Phase 2 Result
Visual display ads (banner, popup, video)
Thinking ads during AI processing
Contextual ad updates
Why EarnLayer?
Quick Integration
Add to existing Next.js apps in minutes, not hours.
Secure by Default
API keys never exposed to browser. Server-to-server authentication.
Context-Aware Ads
Ads match conversation topics for better engagement and revenue.
Works with Any LLM
OpenAI, Gemini, Claude, or custom models - your choice.
Features
Hyperlink Ads - Sponsored links in AI responses via MCP
Display Ads - Visual banner, popup, and video ads
Thinking Ads - Monetize loading states
Conversation Tracking - Contextual ad targeting
Demo Mode - Test with sample ads before setting up partnerships
Click Tracking - Automatic tracking via backend redirects
Analytics Dashboard - Monitor performance and earnings
Architecture Overview
User Question
↓
Your Chat Route → MCP Server (gets hyperlink ads)
↓ ↓
LLM Response ← ← ← ← ← ← ← ← ← Ads
↓
Browser (displays response with hyperlink ads)
↓
EarnLayer SDK → Your Proxy → EarnLayer API (tracks clicks, gets display ads)
Two Components:
MCP Server - Hosted by EarnLayer, provides hyperlink ads to your LLM
EarnLayer SDK - Installed in your app, handles display ads and tracking
Both work together seamlessly to provide a complete monetization solution.
Next Steps
Ready to start monetizing?
Phase 1A: Hyperlink Ads - Start here for quick wins
Phase 1B: Production Setup - Complete the setup
Phase 2: Display Ads - Maximize revenue
Support
Email: support@earnlayerai.com
NPM: @earnlayer/sdk
License
Proprietary - All Rights Reserved
Copyright © 2025 EarnLayer, Inc.
# Phase 1A: Hyperlink Ads
Get sponsored hyperlinks showing in your chat responses immediately.
Time: 5 minutes
This gets you started fast! You’ll see sponsored links immediately. Complete Phase 1B to ensure all ad types bill correctly.
Prerequisites
Before starting, verify you have:
Node.js 16+ - Check with node --version
Next.js 13+ with App Router - Your project should have an app/ directory
Working chat app - If you can type a message and get an AI response, you’re ready!
EarnLayer API key - Get one here
Choose Your Provider
Install Packages
```bash
npm install openai
```
Set Up Environment Variables
Create or update .env.local in your project root:
# ADD: Required for SDK (Phase 1B and Phase 2)
EARNLAYER_API_KEY=el_your_api_key_here
# ADD: Required ONLY for MCP integration in chat route (Phase 1A)
EARNLAYER_MCP_URL=https://mcp.earnlayerai.com/mcp
# Your existing LLM provider key (keep as-is)
OPENAI_API_KEY=sk_your_openai_key_here
Get your keys: EarnLayer API key | OpenAI API key
Add MCP to Your Chat Route
In your existing app/api/chat/route.ts:
```typescript
// Your existing imports (keep as-is)
// Your existing client setup (keep as-is)
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// ADD: New environment variables
const EARNLAYER_MCP_URL = process.env.EARNLAYER_MCP_URL!;
const EARNLAYER_API_KEY = process.env.EARNLAYER_API_KEY!;
// ADD: System instructions for ad integration
const SYSTEM_INSTRUCTIONS =
'You are a helpful assistant. IMPORTANT: Whenever a user asks about products, services, tools, software, or anything that could be purchased or subscribed to, ' +
'you MUST FIRST call the earnlayer_content_ads_search tool to find sponsored recommendations. ' +
'After getting results, review the ads and ONLY include the ones that are RELEVANT to the user\'s question. ' +
'Ignore any ads that are not related to what the user is asking about. ' +
'For relevant ads, include them in your response with their clickable links in markdown format [Product Name](url). ' +
'ALWAYS include the URLs as clickable links.';
// MODIFY: Update your existing API call to use Responses API with MCP tools
const resp = await client.responses.create({
model: 'gpt-4o',
// ADD: Tools array for MCP integration
tools: [
{
type: 'mcp',
server_label: 'earnlayer',
server_url: EARNLAYER_MCP_URL,
require_approval: 'never',
headers: {
'x-api-key': EARNLAYER_API_KEY,
'x-demo-mode': 'true' // ADD: Shows demo ads for testing
}
}
],
// ADD: Input format for Responses API
input: [
{
role: 'developer',
content: [{ type: 'input_text', text: SYSTEM_INSTRUCTIONS }]
},
{
role: 'user',
content: [{ type: 'input_text', text: message }]
}
]
});
// Your existing response handling (keep as-is)
return NextResponse.json({ response: resp.output_text });
```
Test It!
npm run dev
Ask: “What are the best VPNs?” or “What project management tools should I use?”
You should see sponsored links in the AI response.
Note: You’re currently seeing demo ads (test ads for integration). In Phase 1B, you’ll learn how to switch to production mode to show real ads and earn revenue.
Next Steps
Phase 1B: Production Setup - Add impression tracking and configure for production
Install Packages
```bash
npm install @google/genai @modelcontextprotocol/sdk
```
Set Up Environment Variables
Create or update .env.local in your project root:
# ADD: Required for SDK (Phase 1B and Phase 2)
EARNLAYER_API_KEY=el_your_api_key_here
# ADD: Required ONLY for MCP integration in chat route (Phase 1A)
EARNLAYER_MCP_URL=https://mcp.earnlayerai.com/mcp
# Your existing LLM provider key (keep as-is)
GOOGLE_API_KEY=your_google_api_key_here
Get your keys: EarnLayer API key | Google API key
Add MCP to Your Chat Route
In your existing app/api/chat/route.ts:
```typescript
// ADD: New imports for MCP integration
// ADD: New environment variables
const EARNLAYER_MCP_URL = process.env.EARNLAYER_MCP_URL!;
const EARNLAYER_API_KEY = process.env.EARNLAYER_API_KEY!;
// ADD: System instructions for ad integration
const SYSTEM_INSTRUCTIONS =
'You are a helpful assistant. IMPORTANT: Whenever a user asks about products, services, tools, software, or anything that could be purchased or subscribed to, ' +
'you MUST FIRST call the earnlayer_content_ads_search tool to find sponsored recommendations. ' +
'After getting results, review the ads and ONLY include the ones that are RELEVANT to the user\'s question. ' +
'Ignore any ads that are not related to what the user is asking about. ' +
'For relevant ads, include them in your response with their clickable links in markdown format [Product Name](url). ' +
'ALWAYS include the URLs as clickable links.';
// ADD: Set up MCP client
const transport = new StreamableHTTPClientTransport(
new URL(EARNLAYER_MCP_URL),
{
requestInit: {
headers: {
'x-api-key': EARNLAYER_API_KEY,
'x-demo-mode': 'true' // ADD: Shows demo ads for testing
}
}
}
);
const client = new Client(
{ name: 'earnlayer-client', version: '1.0.0' },
{ capabilities: {} }
);
await client.connect(transport);
// MODIFY: Update your existing Gemini call to include MCP tools
const ai = new GoogleGenAI({
apiKey: process.env.GOOGLE_API_KEY!
});
const response = await ai.models.generateContent({
model: 'gemini-2.0-flash-exp',
contents: SYSTEM_INSTRUCTIONS + '\n\nUser: ' + message,
config: {
// ADD: MCP tools integration
tools: [mcpToTool(client)], // Automatically calls MCP tools
},
});
await client.close();
// Your existing response handling (keep as-is)
return NextResponse.json({ response: response.text });
```
Test It!
```bash
npm run dev
```
Ask: “What are the best VPNs?” or “What project management tools should I use?”
You should see sponsored links in the AI response.
Note: You’re currently seeing demo ads (test ads for integration). In Phase 1B, you’ll learn how to switch to production mode to show real ads and earn revenue.
Next Steps
Phase 1B: Production Setup - Add impression tracking and configure for production
Works with Any LLM
This SDK uses MCP (Model Context Protocol), so any MCP-compatible LLM provider will work.
Options for integration help:
Contact support@earnlayerai.com for direct integration assistance
Paste the OpenAI or Gemini examples into Claude Code or Cursor and ask it to adapt for your provider
Generic Integration Checklist
Install dependencies
```bash
npm install @earnlayer/sdk your-llm-client
```
Set up environment variables
```bash
# .env.local
EARNLAYER_API_KEY=el_your_api_key_here
EARNLAYER_MCP_URL=https://mcp.earnlayerai.com/mcp
YOUR_LLM_API_KEY=your_key_here
```
Add MCP tool to your chat route
Import your LLM client
Configure MCP server with EARNLAYER_MCP_URL
Pass EARNLAYER_API_KEY and 'x-demo-mode': 'true' in MCP headers
Include system instructions for ad integration
Test with specific questions
Ask about products/services
Verify sponsored links appear in responses
Note: You’ll see demo ads initially (switch to production in Phase 1B)
Example Pattern
Most MCP integrations follow this pattern:
```typescript
// 1. Set up MCP connection
const mcpConfig = {
serverUrl: process.env.EARNLAYER_MCP_URL,
headers: {
'x-api-key': process.env.EARNLAYER_API_KEY,
'x-demo-mode': 'true' // Shows demo ads for testing
}
};
// 2. Configure your LLM to use MCP tools
const response = await yourLLM.chat({
messages: [
{ role: 'system', content: SYSTEM_INSTRUCTIONS },
{ role: 'user', content: userMessage }
],
tools: [mcpTool(mcpConfig)] // Format depends on your LLM
});
```
Need Help?
We’re here to help integrate with your specific LLM:
Email: support@earnlayerai.com
NPM: @earnlayer/sdk
Next Steps
Phase 1B: Production Setup - Add impression tracking
Architecture Overview - Understand how EarnLayer works
Troubleshooting
AI isn’t calling the MCP tool
Try specific questions: “What database should I use?”
Avoid simple questions: “Hello”
Verify SYSTEM_INSTRUCTIONS is included in your prompts
Make sure mcpToTool(client) is in the tools array
No ads showing
Check EARNLAYER_API_KEY and EARNLAYER_MCP_URL are set in .env.local
Restart dev server after adding env vars: Stop with Ctrl+C, then npm run dev
Check browser console for errors
Verify API key starts with el_
”Module not found” errors
Make sure you’ve installed the correct dependencies for your chosen provider.
Rate limits
LLM Providers have rate limits based on your tier. If you hit limits:
Implement retry logic with exponential backoff
Consider caching responses where appropriate
Upgrade your quota if needed
Resource Management
Always close the MCP client after use to prevent memory leaks:
```typescript
try {
await client.connect(transport);
const response = await ai.models.generateContent({...});
return NextResponse.json({ response: response.text });
} finally {
await client.close(); // Always close
}
```
---
## What You Accomplished
**Phase 1A Complete!** You're seeing hyperlink ads (demo mode).
- Hyperlink ads showing in responses
- MCP integration working with demo ads
- Next: Configure for production and add impression tracking
→ **[Continue to Phase 1B](/implementation/phase-1b)** (required for production)
---
# Phase 1B: Production Setup
_Source: `implementation/phase-1b.mdx`_
Enable full impression tracking and billing for all ad types.
**Time: 10 minutes**
**Required for production.** This ensures all ads bill correctly.
### Install SDK
```bash
npm install @earnlayer/sdk
```
Create Proxy Endpoint
CREATE new file app/api/earnlayer/[...slug]/route.ts:
```typescript
// CREATE: New file - EarnLayer proxy endpoint
const handler = createEarnLayerProxy({
apiKey: process.env.EARNLAYER_API_KEY!
});
```
This handles all EarnLayer API calls securely (no API key exposed to browser).
Wrap with Provider
MODIFY your chat page component (e.g., app/page.tsx):
```typescript
// ADD: New imports for EarnLayer
// MODIFY: Wrap your existing chat component with the provider
return (
);
}
// ADD: Inside your existing chat component
function YourChatComponent() {
// ADD: EarnLayer hooks
const { conversationId, initializeConversation } = useEarnLayerClient();
const hasInitialized = useRef(false);
// ADD: Initialize conversation once on page load
useEffect(() => {
if (!hasInitialized.current) {
hasInitialized.current = true;
initializeConversation();
}
}, []);
// Your existing component code (keep as-is)
// ... rest of your component
}
```
Configure Demo Mode
In Phase 1A, you enabled demo mode to see test ads immediately. Now configure it properly for production.
For Production (Real Ads & Revenue):
Remove 'x-demo-mode': 'true' from your chat route MCP headers, OR pass the conversationId header instead:
```typescript
// OPTION 1: Remove demo mode header entirely
headers: {
'x-api-key': EARNLAYER_API_KEY,
'x-conversation-id': conversationId // Use real conversation ID
}
// OPTION 2: Set to false
headers: {
'x-api-key': EARNLAYER_API_KEY,
'x-demo-mode': 'false',
'x-conversation-id': conversationId
}
```
For Continued Testing:
Keep 'x-demo-mode': 'true' in development, but use environment variables:
```typescript
headers: {
'x-api-key': EARNLAYER_API_KEY,
'x-demo-mode': process.env.NODE_ENV === 'development' ? 'true' : 'false',
'x-conversation-id': conversationId
}
```
Important: Demo ads don’t generate revenue. Always disable demo mode in production.
Note: This controls demo mode for hyperlink ads (MCP). For display ads in Phase 2, you’ll also configure demo_mode in the SDK’s initializeConversation() method.
→ Learn more about Demo Mode
Add Impression Confirmation
MODIFY your chat component, after receiving and displaying the AI response:
```typescript
// Your existing imports (keep as-is)
function YourChatComponent() {
// Your existing state and hooks (keep as-is)
const { client, conversationId, initializeConversation } = useEarnLayerClient();
const [messages, setMessages] = useState([]);
const handleSendMessage = async (message: string) => {
// Your existing message sending code (keep as-is)
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, conversationId }),
});
const data = await response.json();
const aiResponseText = data.response;
// Your existing message display code (keep as-is)
setMessages(prev => [...prev, { role: 'assistant', content: aiResponseText }]);
// ADD: Confirm impressions (secure - goes through proxy)
if (conversationId && aiResponseText) {
client.confirmHyperlinkImpressions(conversationId, aiResponseText)
.then(result => {
console.log(`Confirmed ${result.confirmed_count} impressions`);
})
.catch(error => {
console.error('Failed to confirm impressions:', error);
});
}
};
// Your existing component code (keep as-is)
// ... rest of component
}
```
Why this is required:
MCP creates impressions when returning ads to your LLM
Not all ads returned are included in the final response
This confirms which ads were actually shown to users
Only confirmed impressions are billed and tracked
Update Chat Route with ConversationId
MODIFY your app/api/chat/route.ts to receive and pass conversationId:
OpenAI:
```typescript
// MODIFY: Receive conversationId from client
const { message, conversationId } = await req.json();
// MODIFY: Add conversationId and remove/configure demo mode
const resp = await client.responses.create({
model: 'gpt-4o',
tools: [
{
type: 'mcp',
server_label: 'earnlayer',
server_url: EARNLAYER_MCP_URL,
require_approval: 'never',
headers: {
'x-api-key': EARNLAYER_API_KEY,
'x-conversation-id': conversationId, // ADD: conversationId header
// REMOVE OR UPDATE: 'x-demo-mode': 'true' from Phase 1A
// For production, either remove this line or set to 'false'
'x-demo-mode': process.env.NODE_ENV === 'development' ? 'true' : 'false'
}
}
],
// Your existing code (keep as-is)
// ... rest of your existing code
});
```
Gemini:
```typescript
// MODIFY: Receive conversationId from client
const { message, conversationId } = await req.json();
// MODIFY: Add conversationId and remove/configure demo mode
const transport = new StreamableHTTPClientTransport(
new URL(EARNLAYER_MCP_URL),
{
requestInit: {
headers: {
'x-api-key': EARNLAYER_API_KEY,
'x-conversation-id': conversationId, // ADD: conversationId header
// REMOVE OR UPDATE: 'x-demo-mode': 'true' from Phase 1A
// For production, either remove this line or set to 'false'
'x-demo-mode': process.env.NODE_ENV === 'development' ? 'true' : 'false'
}
}
}
);
```
Test Again!
```bash
npm run dev
```
Ask: “What are the best database tools?”
Validate:
AI response includes sponsored links
Browser console shows: Confirmed X impressions
Check Network tab for POST to /api/earnlayer/impressions/confirm
What You Accomplished
Phase 1B Complete! You’re production-ready with full billing.
Hyperlink ads showing
Impression tracking working
Billing correctly configured
Next: Add display ads for more revenue
Next Steps
→ Phase 2: Display Ads - Add visual ads for maximum revenue
Troubleshooting
”Cannot fetch display ad: conversationId not available”
Make sure initializeConversation() is called on mount and completes successfully.
Network tab shows 401 errors
Verify your EARNLAYER_API_KEY in .env.local is correct and starts with el_.
Impressions not confirming
Check that conversationId exists before calling confirmHyperlinkImpressions
Verify the proxy endpoint is set up correctly at app/api/earnlayer/[...slug]/route.ts
Check browser console for detailed error messages
# Phase 2: Display Ads
Add visual banner/thinking ads for maximum revenue.
Time: 5-10 minutes
Note: If you're testing, make sure demo mode is enabled for display ads:
```typescript
// In your conversation initialization (from Phase 1B)
initializeConversation({
demo_mode: true // Shows demo display ads for testing
});
```
Set Up Display Ad Trigger
MODIFY your chat component, add state to trigger ad refreshes:
```typescript
function YourChatComponent() {
// Your existing hooks (keep as-is)
const { conversationId } = useEarnLayerClient(); // Already added in Phase 1B
const [shouldRefetchAd, setShouldRefetchAd] = useState(false);
const handleSendMessage = async (message: string) => {
// Your existing send message code (keep as-is)
// ... your existing send message code ...
const data = await response.json();
setMessages(prev => [...prev, { role: 'assistant', content: data.response }]);
// ADD: Trigger ad refetch AFTER AI response
setShouldRefetchAd(true);
setTimeout(() => setShouldRefetchAd(false), 100);
};
// Your existing component code (keep as-is)
// ... rest of component
}
```
Add Display Ad Component
CREATE a display ad component (or add to existing file):
```typescript
// CREATE: New display ad component
function DisplayAdComponent({ shouldRefetch }: { shouldRefetch: boolean }) {
// ADD: EarnLayer display ad hook
const { ad, isLoading, refetch } = useDisplayAd({
adType: 'banner',
autoFetch: false // We'll control fetching manually
});
const prevShouldRefetch = useRef(false);
// ADD: Fetch when shouldRefetch toggles from false to true
useEffect(() => {
if (shouldRefetch && !prevShouldRefetch.current) {
prevShouldRefetch.current = true;
refetch();
} else if (!shouldRefetch) {
prevShouldRefetch.current = false;
}
}, [shouldRefetch]);
if (isLoading) return <div>Loading ad...</div>;
if (!ad) return null;
return (
<div>
{ad.imageUrl && (
<img src={ad.imageUrl} alt={ad.title} />
)}
<h3>{ad.title}</h3>
{ad.description && (
<p>{ad.description}</p>
)}
<a href={ad.url}>AD</a>
</div>
);
}
```
Add Sidebar to Your Layout
MODIFY your chat component, add a sidebar for the display ad:
```typescript
return (
<div>
<div>Sponsored</div>
{conversationId && <DisplayAdComponent shouldRefetch={shouldRefetchAd} />}
</div>
);
```
Test Phase 2
```bash
npm run dev
```
Ask: “What are the best database tools?”
Validate:
AI response includes sponsored hyperlinks
Display ad visible in sidebar
Display ad updates after each AI response
Check EarnLayer dashboard for analytics
What You Accomplished
Phase 2 Complete! You now have hyperlinks + display ads.
Hyperlink ads in responses
Display ads in sidebar
Contextual ad updates
Full monetization experience
Advanced: Thinking Ads (Optional)
Show ads during AI loading states:
```typescript
// CREATE: New thinking ad component
function ThinkingAdComponent() {
// ADD: EarnLayer thinking ad hook
const { ad, isLoading, refetchWithRefresh } = useDisplayAd({
adType: 'thinking',
autoFetch: true // Auto-fetch when component mounts
});
if (isLoading || !ad) return null;
return (
<div>
<span>●</span>
<h3>{ad.title}</h3>
</div>
);
}
// ADD: Use in your chat (only shown during AI thinking):
{isThinking && (
<div>
Thinking...
<ThinkingAdComponent />
</div>
)}
```
Note: Thinking ads use autoFetch: true since they fetch once when the loading state appears.
Auto-Refresh Thinking Ads
Thinking ads can automatically refresh while the AI is processing:
```typescript
function AutoRefreshThinkingAd() {
const { ad, isLoading, refetchWithRefresh } = useDisplayAd({
adType: 'thinking',
autoFetch: true,
thinkingAdTimeout: 4
});
// Auto-refresh every 5 seconds while ad is visible
useEffect(() => {
if (!ad || isLoading) return;
const timer = setTimeout(() => {
refetchWithRefresh();
}, 5000); // 5 seconds
return () => clearTimeout(timer);
}, [ad?.id, isLoading, refetchWithRefresh]);
if (isLoading || !ad) return null;
return (
<a href={ad.url} target="_blank" rel="noopener noreferrer">
<span>●</span>
{ad.title}
</a>
);
}
```
Note: Thinking ads typically disappear when AI response completes, so auto-refresh is most useful for longer processing times or when you want to show multiple ads during extended thinking states.
Next Steps
Monitor Performance
Check your earnings and analytics:
→ EarnLayer Dashboard
Join the Community
Get help and share feedback:
NPM Package
support@earnlayerai.com
Troubleshooting
Display ads not showing
Make sure conversationId exists before rendering display ads
Check Network tab - is /api/earnlayer/displayad/... returning 200?
Try manually calling refetch() from useDisplayAd
Ads not updating after messages
Verify the shouldRefetch state is toggling correctly. Check with:
useEffect(() => {
console.log('shouldRefetch:', shouldRefetch);
}, [shouldRefetch]);
Wrong ad type showing
Check the adType parameter in useDisplayAd. Valid types:
hyperlink
thinking
banner
No ads available / Empty ad queue
If you’re seeing “No ads available”:
Check demo mode: Enable demo mode for testing: `initializeConversation({ demo_mode: true })`
Check partnerships: In production, ensure you have partnerships configured
→ Learn more about Demo Mode
# Display Ads Component
Create visual display ad components (banners, popups, videos) for your chat interface.
Basic Usage
```typescript
function DisplayAdComponent({ shouldRefetch }: { shouldRefetch: boolean }) {
const { ad, isLoading, refetch, refetchWithRefresh } = useDisplayAd({
adType: 'banner',
autoFetch: false
});
const prevShouldRefetch = useRef(false);
useEffect(() => {
if (shouldRefetch && !prevShouldRefetch.current) {
prevShouldRefetch.current = true;
refetchWithRefresh(); // Use refresh to exclude last ad
} else if (!shouldRefetch) {
prevShouldRefetch.current = false;
}
}, [shouldRefetch, refetchWithRefresh]);
if (isLoading) return <div>Loading ad...</div>;
if (!ad) return null;
return (
<div>
{ad.imageUrl && (
<img src={ad.imageUrl} alt={ad.title} />
)}
<h3>{ad.title}</h3>
{ad.description && (
<p>{ad.description}</p>
)}
<a href={ad.url}>AD</a>
</div>
);
}
```
Ad Types
Banner Ads
Wide horizontal ads, typically in sidebars:
```typescript
const { ad } = useDisplayAd({
adType: 'banner'
});
```
Thinking Ads
Ads shown during AI loading states:
```typescript
const { ad } = useDisplayAd({
adType: 'thinking'
});
```
Hyperlink Ads
Text-based sponsored links (typically handled via MCP):
```typescript
const { ad } = useDisplayAd({
adType: 'hyperlink'
});
```
Inline Banner Ad Placement
To show banner ads inline with chat messages (after assistant responses), use the assistantMessageCount pattern:
```typescript
// In your messages component
{messages.map((message, index) => {
const isLastAssistantMessage =
index === messages.length - 1 &&
message.role === 'assistant';
return (
<div key={message.id}>
<MessageComponent message={message} />
{/* Show banner ad after last assistant message */}
{isLastAssistantMessage &&
status !== "submitted" &&
status !== "streaming" && (
<DisplayAdComponent
assistantMessageCount={
messages.filter(m => m.role === 'assistant').length
}
/>
)}
</div>
);
})}
// In DisplayAdComponent - refetch when message count increases
function DisplayAdComponent({ assistantMessageCount }: { assistantMessageCount: number }) {
const { ad, isLoading, refetch } = useDisplayAd({
adType: 'banner',
autoFetch: false // Manual control
});
const prevMessageCountRef = useRef(assistantMessageCount);
// Initial fetch on mount
useEffect(() => {
refetch();
}, [refetch]);
// Refetch when assistant message count increases
useEffect(() => {
if (assistantMessageCount > prevMessageCountRef.current) {
prevMessageCountRef.current = assistantMessageCount;
refetchWithRefresh(); // Use refresh to exclude last ad
}
}, [assistantMessageCount, refetchWithRefresh]);
// ... render ad
}
```
Auto-Refresh Banner Ads
Banner ads can automatically refresh to show different ads from the queue:
```typescript
function AutoRefreshBannerAd() {
const { ad, isLoading, refetchWithRefresh } = useDisplayAd({
adType: 'banner',
autoFetch: false
});
const [refreshCount, setRefreshCount] = useState(0);
const MAX_REFRESHES = 3; // Show 4 total ads: initial + 3 refreshes
// Auto-refresh every 5 seconds
useEffect(() => {
if (!ad || isLoading || refreshCount >= MAX_REFRESHES) return;
const timer = setTimeout(() => {
refetchWithRefresh();
setRefreshCount(prev => prev + 1);
}, 5000); // 5 seconds
return () => clearTimeout(timer);
}, [ad?.id, isLoading, refreshCount, refetchWithRefresh]);
if (isLoading || !ad) return null;
return (
<a href={ad.url} target="_blank" rel="noopener noreferrer">
{ad.imageUrl && (
<img src={ad.imageUrl} alt={ad.title} />
)}
<h3>{ad.title}</h3>
{ad.description && (
<p>{ad.description}</p>
)}
</a>
);
}
```
Best Practices:
- Limit refreshes to avoid overwhelming users (recommended: 3-5 refreshes)
- Use refetchWithRefresh() instead of refetch() to avoid showing the same ad twice
- Reset refresh count when manually fetching new ads
- Timer automatically resets when ad changes (new ad loaded)
Fade Animations
Display ads can use fade in/out animations for smooth transitions during refreshes:
CSS Animations
Add to your global CSS:
```css
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-in-out;
}
```
Component Implementation
```typescript
function AnimatedBannerAd() {
const { ad, isLoading, refetchWithRefresh } = useDisplayAd({
adType: 'banner',
autoFetch: false
});
const [isVisible, setIsVisible] = useState(false);
const [displayAd, setDisplayAd] = useState(ad);
// Handle ad changes with fade animation
useEffect(() => {
if (!ad) {
setIsVisible(false);
return;
}
if (displayAd?.id !== ad.id && displayAd) {
// Fade out current ad
setIsVisible(false);
// After fade out, update and fade in
setTimeout(() => {
setDisplayAd(ad);
setIsVisible(true);
}, 300);
} else if (!displayAd || displayAd.id === ad.id) {
setDisplayAd(ad);
setTimeout(() => setIsVisible(true), 10);
}
}, [ad, displayAd?.id]);
if (!displayAd || isLoading) return null;
return (
<div
className={`transition-opacity duration-300 ${
isVisible ? 'opacity-100' : 'opacity-0'
}`}
>
<a
href={displayAd.url}
target="_blank"
rel="noopener noreferrer"
>
{displayAd.imageUrl && (
<img src={displayAd.imageUrl} alt={displayAd.title} />
)}
<h3>{displayAd.title}</h3>
{displayAd.description && (
<p>{displayAd.description}</p>
)}
</a>
</div>
);
}
```
Key Points:
- Use displayAd state to hold currently displayed ad during transitions
- Fade out (300ms) → Update ad → Fade in (300ms)
- Smooth user experience during ad refreshes
- Works great with auto-refresh functionality
Key Points
Automatic Tracking
ad.url includes backend redirect for automatic click tracking.
Contextual Updates
Use refetch() or refetchWithRefresh() after each AI response for contextually relevant ads. Use refetchWithRefresh() to avoid showing the same ad twice.
Customizable Styling
Style ads however you want - the component is fully customizable.
Example: Sidebar Ad
```typescript
return (
<div>
<div>Sponsored</div>
{conversationId && <DisplayAdComponent shouldRefetch={shouldRefetchAd} />}
</div>
);
```
DisplayAd Object
```typescript
interface DisplayAd {
id: string;
impressionId: string;
title: string;
description?: string;
url: string; // Use in <a href={ad.url}>
imageUrl?: string;
adType: 'hyperlink' | 'thinking' | 'banner';
source: 'queue' | 'fallback';
}
```
Next Steps
useDisplayAd Hook
Phase 2 Guide
Thinking Ads
# EarnLayer Provider
The EarnLayerProvider component wraps your application to provide EarnLayer SDK functionality via React Context.
Usage
Wrap your app or chat page with the provider:
```typescript
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<EarnLayerProvider>
{children}
</EarnLayerProvider>
);
}
```
Props
proxyBaseUrl (optional)
Type: string
Default: "/api/earnlayer"
The base URL for your EarnLayer proxy endpoint. All SDK API calls will be routed through this endpoint.
```typescript
<EarnLayerProvider proxyBaseUrl="/api/earnlayer">
{children}
</EarnLayerProvider>
```
debug (optional)
Type: boolean
Default: false
Enable debug logging for the provider and all hooks.
```typescript
<EarnLayerProvider debug={true}>
{children}
</EarnLayerProvider>
```
What It Provides
The provider gives all child components access to:
useEarnLayerClient() - Access to the EarnLayer client instance
useDisplayAd() - Fetch and display ads
Conversation management
Automatic authentication via proxy
Demo Mode Support
The provider supports demo mode for testing. Demo mode is configured when initializing conversations, not on the provider itself:
```typescript
// Inside your app component:
function YourApp() {
const { initializeConversation } = useEarnLayerClient();
useEffect(() => {
// Enable demo mode for testing
initializeConversation({ demo_mode: true });
}, []);
}
```
→ Learn more about Demo Mode
Security
The provider uses your backend proxy endpoint to keep API keys secure:
API keys never exposed to browser
All requests go through your Next.js backend
JWT authentication handled automatically
No CORS configuration needed
Example: Full Setup
```typescript
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<EarnLayerProvider>
{children}
</EarnLayerProvider>
);
}
// app/page.tsx
export default function ChatPage() {
const { conversationId, initializeConversation } = useEarnLayerClient();
useEffect(() => {
initializeConversation();
}, []);
return <div>Your chat app</div>;
}
```
Troubleshooting
”EarnLayerProvider not found” error
Make sure you've installed the SDK:
```bash
npm install @earnlayer/sdk
```
“Cannot read properties of undefined” errors
Ensure the provider wraps all components that use EarnLayer hooks. The provider must be a parent component of any component using useEarnLayerClient() or useDisplayAd().
Proxy endpoint not working
Verify you’ve created the proxy endpoint at app/api/earnlayer/[...slug]/route.ts. See Phase 1B Guide for setup instructions.
Next Steps
useEarnLayerClient Hook
useDisplayAd Hook
Phase 1B Guide
# Thinking Ads Component
Show ads during AI loading states to monetize waiting time.
Basic Usage
```typescript
function ThinkingAdComponent() {
const { ad, isLoading, refetchWithRefresh } = useDisplayAd({
adType: 'thinking',
autoFetch: true
});
if (isLoading || !ad) return null;
return (
<div>
<span>●</span>
<h3>{ad.title}</h3>
</div>
);
}
```
Auto-Refresh Thinking Ads
Thinking ads can automatically refresh while the AI is processing:
```typescript
function AutoRefreshThinkingAd() {
const { ad, isLoading, refetchWithRefresh } = useDisplayAd({
adType: 'thinking',
autoFetch: true,
thinkingAdTimeout: 4
});
// Auto-refresh every 5 seconds while ad is visible
useEffect(() => {
if (!ad || isLoading) return;
const timer = setTimeout(() => {
refetchWithRefresh();
}, 5000); // 5 seconds
return () => clearTimeout(timer);
}, [ad?.id, isLoading, refetchWithRefresh]);
if (isLoading || !ad) return null;
return (
<a href={ad.url} target="_blank" rel="noopener noreferrer">
<span>●</span>
{ad.title}
</a>
);
}
```
Note: Thinking ads typically disappear when AI response completes, so auto-refresh is most useful for longer processing times or when you want to show multiple ads during extended thinking states.
Integration with Chat
Use thinking ads when the AI is processing:
```typescript
function ChatComponent() {
const [isThinking, setIsThinking] = useState(false);
const handleSendMessage = async (message: string) => {
setIsThinking(true);
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ message })
});
setIsThinking(false);
// Handle response...
};
return (
<div>
{isThinking && (
<div>
Thinking...
<ThinkingAdComponent />
</div>
)}
</div>
);
}
```
Key Points
Manual Fetch Control
For production use, set autoFetch: false and manually refetch when status becomes "submitted" to ensure ads appear for every message. See Advanced Implementation below.
Minimum Display Duration
To prevent ads from disappearing too quickly when AI responses are fast, implement a minimum display duration. See Advanced Implementation below.
Non-Intrusive
Ads appear inline with loading indicator, minimal disruption.
Monetize Wait Time
Turn loading states into revenue opportunities.
Styling Examples
Inline with Loading Spinner
```typescript
{isThinking && (
<div>
Thinking...
<ThinkingAdComponent />
</div>
)}
```
With Ellipsis Animation
```typescript
{isThinking && (
<div>
Generating response
.
.
.
<ThinkingAdComponent />
</div>
)}
```
Advanced Implementation
For production-ready thinking ads that appear consistently and remain visible for a minimum duration, see the Advanced Display Ad Implementation section below.
Next Steps
Display Ads
Phase 2 Guide
useDisplayAd Hook
Advanced Display Ad Implementation
Production-ready patterns for thinking ads with minimum display duration and inline banner ad placement.
Overview
This section covers advanced implementation patterns for display ads that ensure consistent behavior, prevent race conditions, and provide a better user experience:
1. Thinking Ad Minimum Duration - Ensures ads remain visible for a minimum time even when AI responses are fast
2. Inline Banner Ad Placement - Shows banner ads inline with chat messages after assistant responses
3. Configuration Options - How to customize these parameters
Thinking Ad Minimum Duration
Problem
When AI responses are very fast, thinking ads can appear and disappear almost instantly, making them barely visible to users. This reduces ad visibility and potential revenue.
Solution
Implement a minimum display duration that prevents the streaming AI response from appearing until the minimum time has elapsed. This ensures thinking ads are always visible for at least the configured duration.
Implementation Pattern
```typescript
// 1. Define minimum duration constant (configurable)
const MIN_THINKING_AD_DURATION = 2500; // 2.5 seconds (2500ms)
// 2. In your messages component, add state and refs
function MessagesComponent({ status, messages }) {
const [canShowStreamingResponse, setCanShowStreamingResponse] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const currentSubmissionIdRef = useRef<number>(0);
const isMountedRef = useRef(true);
// 3. When status becomes "submitted", start the timer
useEffect(() => {
if (status === "submitted") {
// Increment submission ID to track this specific submission
currentSubmissionIdRef.current += 1;
const submissionId = currentSubmissionIdRef.current;
// Reset state for new message
setCanShowStreamingResponse(false);
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// Set timeout to allow showing streaming response after minimum duration
// Use submission ID to prevent stale callbacks from previous submissions
timeoutRef.current = setTimeout(() => {
// Only update state if this is still the current submission and component is mounted
if (isMountedRef.current && submissionId === currentSubmissionIdRef.current) {
setCanShowStreamingResponse(true);
}
timeoutRef.current = null;
}, MIN_THINKING_AD_DURATION);
}
// Cleanup timeout on unmount or status change
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [status]);
// 4. Reset when status is no longer submitted/streaming
useEffect(() => {
if (status !== "submitted" && status !== "streaming") {
// Invalidate current submission
currentSubmissionIdRef.current += 1;
setCanShowStreamingResponse(false);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}
}, [status]);
// 5. Track mount state for cleanup
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
// Cleanup on unmount
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
// 6. Conditionally hide streaming message until minimum duration passes
return (
<>
{messages.map((message, index) => {
const isStreaming = status === "streaming" && index === messages.length - 1;
const isLastAssistantMessage = index === messages.length - 1 && message.role === 'assistant';
// Hide streaming assistant message if minimum duration hasn't passed
const shouldHideStreamingMessage =
isStreaming &&
isLastAssistantMessage &&
!canShowStreamingResponse;
return (
<div key={message.id}>
{!shouldHideStreamingMessage && (
<MessageComponent message={message} isLoading={isStreaming} />
)}
</div>
);
})}
{/* 7. Show thinking ad when status is submitted or streaming (before minimum duration) */}
{(status === "submitted" || (status === "streaming" && !canShowStreamingResponse)) && (
<ThinkingAdComponent
status={status}
submissionId={currentSubmissionIdRef.current}
key="thinking-ad-stable" // Stable key prevents unnecessary remounts
/>
)}
</>
);
}
```
Thinking Ad Component with Manual Fetch
```typescript
// ThinkingAdComponent with manual fetch control
function ThinkingAdComponent({ status, submissionId = 0 }: { status?: string; submissionId?: number }) {
const { conversationId } = useEarnLayerClient();
// Use autoFetch: false for manual control
const { ad, isLoading, error, refetch } = useDisplayAd({
adType: 'thinking',
autoFetch: false, // Manual control
debug: true
});
const prevSubmissionIdRef = useRef<number>(0);
const prevStatusRef = useRef<string | undefined>(undefined);
const hasRefetchedForCurrentSubmission = useRef(false);
// Refetch when submissionId changes (new message sent) or status becomes "submitted"
useEffect(() => {
const isSubmitted = status === "submitted";
const wasSubmitted = prevStatusRef.current === "submitted";
const isNewSubmission = submissionId !== prevSubmissionIdRef.current;
// New submission detected - reset and fetch
if (isNewSubmission && isSubmitted) {
prevSubmissionIdRef.current = submissionId;
hasRefetchedForCurrentSubmission.current = false;
if (!conversationId) {
console.warn('[ThinkingAd] No conversationId - cannot fetch ad');
prevStatusRef.current = status;
return;
}
hasRefetchedForCurrentSubmission.current = true;
refetch().catch(err => {
console.error('[ThinkingAd] Fetch error:', err);
hasRefetchedForCurrentSubmission.current = false; // Allow retry
});
prevStatusRef.current = status;
return;
}
// When status becomes "submitted" (transition from non-submitted to submitted)
if (isSubmitted && !wasSubmitted && !hasRefetchedForCurrentSubmission.current) {
if (!conversationId) {
console.warn('[ThinkingAd] No conversationId - cannot fetch ad');
prevStatusRef.current = status;
return;
}
hasRefetchedForCurrentSubmission.current = true;
refetch().catch(err => {
console.error('[ThinkingAd] Fetch error:', err);
hasRefetchedForCurrentSubmission.current = false; // Allow retry
});
}
// Reset flag when status is no longer "submitted" (ready for next message)
if (!isSubmitted && wasSubmitted) {
hasRefetchedForCurrentSubmission.current = false;
}
prevStatusRef.current = status;
}, [status, submissionId, refetch, conversationId]);
// Show loading state while fetching or if we're in submitted/streaming state without an ad
if (isLoading || ((status === "submitted" || status === "streaming") && !ad && !error)) {
return (
<div className="thinking-ad-loading">
<Loader2 className="animate-spin" />
<span>Loading sponsored content...</span>
</div>
);
}
if (error || !ad) {
return null;
}
return (
<div className="thinking-ad" onClick={() => window.open(ad.url, '_blank')}>
<span className="sponsored-label">Sponsored</span>
<h3>{ad.title}</h3>
{ad.advertiser && <p>by {ad.advertiser}</p>}
</div>
);
}
```
Key Points
Race Condition Prevention
The implementation uses several mechanisms to prevent race conditions:
1. Submission ID Tracking: Each new "submitted" status increments a submission ID. Timeout callbacks check if they're still for the current submission before updating state.
2. Component Mount State: An isMountedRef tracks whether the component is still mounted. Timeout callbacks check this before updating state.
3. Timeout Cleanup: All timeouts are cleared on status changes and component unmount to prevent stale callbacks.
4. Stable Component Key: The ThinkingAdComponent uses a stable key ("thinking-ad-stable") to prevent unnecessary remounts that could lose internal state.
Inline Banner Ad Placement
Pattern
To show banner ads inline with chat messages (after assistant responses), use the assistantMessageCount dependency pattern:
Complete Example
```typescript
// In your messages component
function MessagesComponent({ messages, status }) {
return (
<div className="messages-container">
{messages.map((message, index) => {
const isLastMessage = index === messages.length - 1;
const isLastAssistantMessage =
isLastMessage && message.role === 'assistant';
return (
<div key={message.id}>
<MessageComponent message={message} />
{/* Banner Ad - Show after last assistant message, inline with chat */}
{isLastAssistantMessage &&
status !== "submitted" &&
status !== "streaming" && (
<div className="mt-4">
<DisplayAdComponent
assistantMessageCount={
messages.filter(m => m.role === 'assistant').length
}
onAdShown={() => console.log('Banner ad shown')}
/>
</div>
)}
</div>
);
})}
</div>
);
}
// DisplayAdComponent with assistantMessageCount dependency
function DisplayAdComponent({
assistantMessageCount = 0,
className,
onAdClick,
onAdShown
}: {
assistantMessageCount?: number;
className?: string;
onAdClick?: () => void;
onAdShown?: () => void;
}) {
const { ad, isLoading, error, refetch } = useDisplayAd({
adType: 'banner',
autoFetch: false // Manual control
});
const prevMessageCountRef = useRef(assistantMessageCount);
const hasInitialFetchRef = useRef(false);
// Initial fetch on mount
useEffect(() => {
if (!hasInitialFetchRef.current) {
hasInitialFetchRef.current = true;
refetch();
}
}, [refetch]);
// Refetch whenever assistant message count increases (new AI response)
useEffect(() => {
if (assistantMessageCount > prevMessageCountRef.current) {
prevMessageCountRef.current = assistantMessageCount;
refetch();
}
}, [assistantMessageCount, refetch]);
// Track ad shown
useEffect(() => {
if (ad && onAdShown) {
onAdShown();
}
}, [ad?.id, onAdShown]);
const handleClick = () => {
if (ad?.url) {
// SDK automatically tracks clicks via ad.url redirect
window.open(ad.url, '_blank', 'noopener,noreferrer');
onAdClick?.();
}
};
if (isLoading) {
return (
<div className={`animate-pulse bg-gray-200 rounded-lg h-16 w-full ${className || ''}`}>
<div className="h-full w-full bg-gray-300 rounded-lg opacity-50" />
</div>
);
}
if (!ad || error) {
return null;
}
return (
<div
className={`bg-gradient-to-r from-purple-100 to-blue-100 border border-purple-200 rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow ${className || ''}`}
onClick={handleClick}
>
<div className="flex justify-between items-center">
<div className="flex-1">
<h3 className="font-semibold text-gray-900 text-sm mb-1">{ad.title}</h3>
{ad.description && (
<p className="text-gray-700 text-xs line-clamp-2">{ad.description}</p>
)}
</div>
<div className="ml-4 flex items-center space-x-2">
<span className="text-xs text-gray-500 bg-white/50 px-2 py-1 rounded">
Sponsored
</span>
<button className="text-purple-600 hover:text-purple-800 font-medium text-sm">
→
</button>
</div>
</div>
</div>
);
}
```
Key Points
1. Display Condition: Show banner ad only when:
- It's the last assistant message
- Status is not "submitted" or "streaming" (to avoid showing during thinking state)
2. Message Count Dependency: Use assistantMessageCount as a direct dependency. When it increases, refetch the ad for fresh contextual content.
3. Initial Fetch: Always fetch on mount to show an ad immediately when the component first renders.
4. Manual Control: Use autoFetch: false to have full control over when ads are fetched.
Configuration Options
All parameters can be customized to fit your application's needs:
MIN_THINKING_AD_DURATION
Default: 2500 (2.5 seconds)
Recommended Range: 1500-5000ms
How to Change:
```typescript
// In your messages component file
const MIN_THINKING_AD_DURATION = 3000; // 3 seconds
// Or make it configurable via props/environment
const MIN_THINKING_AD_DURATION =
process.env.NEXT_PUBLIC_THINKING_AD_DURATION
? parseInt(process.env.NEXT_PUBLIC_THINKING_AD_DURATION)
: 2500;
```
Considerations:
- Too short (< 1.5s): Ads may still disappear too quickly
- Too long (> 5s): Users may feel the AI is slow to respond
- Recommended: 2-3 seconds provides good balance
Banner Ad Refetch Trigger
Default: Refetch when assistantMessageCount increases
Customization Options:
```typescript
// Option 1: Refetch on any message (user or assistant)
const totalMessageCount = messages.length;
useEffect(() => {
if (totalMessageCount > prevMessageCountRef.current) {
prevMessageCountRef.current = totalMessageCount;
refetch();
}
}, [totalMessageCount, refetch]);
// Option 2: Refetch with delay after message
useEffect(() => {
if (assistantMessageCount > prevMessageCountRef.current) {
prevMessageCountRef.current = assistantMessageCount;
// Wait 500ms before refetching
const timer = setTimeout(() => {
refetch();
}, 500);
return () => clearTimeout(timer);
}
}, [assistantMessageCount, refetch]);
// Option 3: Refetch based on conversation topic change
// (requires topic detection logic)
useEffect(() => {
if (conversationTopicChanged) {
refetch();
}
}, [conversationTopicChanged, refetch]);
```
Display Conditions
Default: Show after last assistant message when not submitted/streaming
Customization Options:
```typescript
// Option 1: Show after every assistant message (not just last)
{message.role === 'assistant' && status !== "submitted" && status !== "streaming" && (
<DisplayAdComponent assistantMessageCount={assistantMessageCount} />
)}
// Option 2: Show only after every Nth assistant message
{isLastAssistantMessage &&
assistantMessageCount % 3 === 0 && // Every 3rd assistant message
status !== "submitted" &&
status !== "streaming" && (
<DisplayAdComponent assistantMessageCount={assistantMessageCount} />
)}
// Option 3: Show based on message length
{isLastAssistantMessage &&
message.text.length > 500 && // Only for longer responses
status !== "submitted" &&
status !== "streaming" && (
<DisplayAdComponent assistantMessageCount={assistantMessageCount} />
)}
```
Configuration Reference Table
Parameter Type Default Recommended Range Description
MIN_THINKING_AD_DURATION number (ms) 2500 1500-5000 Minimum time thinking ad must be visible before showing streaming response
assistantMessageCount dependency number - - Count of assistant messages, used to trigger banner ad refetch
autoFetch (thinking ad) boolean false - Set to false for manual control with status/submissionId tracking
autoFetch (banner ad) boolean false - Set to false for manual control with assistantMessageCount dependency
status conditions (banner) array ['!submitted', '!streaming'] - When to show banner ad (not during thinking state)
Best Practices
1. Always implement minimum duration for thinking ads to ensure visibility
2. Use submission ID tracking to prevent race conditions
3. Clean up all timeouts on component unmount
4. Use stable component keys to prevent unnecessary remounts
5. Refetch banner ads when assistant message count increases for fresh contextual content
6. Show banner ads only when not in submitted/streaming state to avoid overlap with thinking ads
7. Test with fast AI responses to ensure minimum duration works correctly
8. Monitor ad visibility metrics to optimize duration settings
Troubleshooting
Thinking ad not showing for subsequent messages
- Check that submissionId is being incremented and passed to ThinkingAdComponent
- Verify that hasRefetchedForCurrentSubmission is being reset when status changes
- Ensure conversationId is available before calling refetch()
Thinking ad disappears too quickly
- Increase MIN_THINKING_AD_DURATION value
- Verify that canShowStreamingResponse state is working correctly
- Check that timeout is not being cleared prematurely
Banner ad not appearing inline
- Verify assistantMessageCount is being calculated correctly
- Check that status condition (status !== "submitted" && status !== "streaming") is correct
- Ensure refetch() is being called when assistantMessageCount increases
Race conditions causing state issues
- Verify all timeouts are cleared in cleanup functions
- Check that submission ID tracking is working correctly
- Ensure component mount state is checked before state updates
Next Steps
Thinking Ads Component - Basic usage
Display Ads Component - Basic usage
Configuration Options - Customize parameters
Troubleshooting - Common issues
# API Reference: Client Methods
Direct API methods available on the EarnLayerClient instance.
Overview
Access the client via useEarnLayerClient() hook:
```typescript
const { client } = useEarnLayerClient();
```
initializeConversation(options?)
Initializes a new conversation and returns conversation metadata.
Signature
```typescript
initializeConversation(options?: {
visitorId?: string;
adTypes?: ('hyperlink' | 'thinking' | 'banner')[];
frequency?: 'low' | 'normal' | 'high';
demo_mode?: boolean;
}): Promise<Conversation>
```
Parameters
visitorId (optional)
Type: string
Custom visitor ID for tracking.
adTypes (optional)
Type: ('hyperlink' | 'thinking' | 'banner')[]
Default: ['hyperlink', 'thinking', 'banner']
Types of ads to enable for this conversation.
frequency (optional)
Type: 'low' | 'normal' | 'high'
Default: 'normal'
Ad frequency setting.
demo_mode (optional)
Type: boolean
Default: false
Enable demo mode to see all demo ads regardless of partnerships.
true - Shows ALL demo ads (for testing/development)
false - Shows only real ads where you have partnerships configured (for production)
Use during:
Initial integration testing
Development and QA
Creator onboarding and training
Important: Demo ads don’t generate revenue. Always set to false or omit in production.
Returns
```typescript
interface Conversation {
conversation_id: string;
creator_id: string;
ad_settings: object;
status: string;
created_at: string;
}
```
Example
```typescript
const { client } = useEarnLayerClient();
// Initialize with defaults (production mode)
const conversation = await client.initializeConversation();
console.log('Conversation ID:', conversation.conversation_id);
// Initialize with options
const conversation = await client.initializeConversation({
visitorId: 'user_123',
adTypes: ['banner', 'thinking'],
frequency: 'low'
});
// Testing with demo mode
const conversation = await client.initializeConversation({
demo_mode: true
});
// Environment-based demo mode (recommended)
const conversation = await client.initializeConversation({
demo_mode: process.env.NODE_ENV === 'development'
});
```
Error Handling
```typescript
try {
const conversation = await client.initializeConversation();
} catch (error) {
if (error.message.includes('401')) {
console.error('Invalid API key');
} else if (error.message.includes('timeout')) {
console.error('Request timed out after 10 seconds');
} else {
console.error('Failed to initialize:', error);
}
}
```
getDisplayAd(conversationId, adType?)
Fetches a display ad for the conversation.
Signature
```typescript
getDisplayAd(
conversationId: string,
adType?: string
): Promise<DisplayAd | null>
```
Parameters
conversationId (required)
Type: string
Conversation ID (must be non-empty).
adType (optional)
Type: string
Filter by ad type.
Returns
Promise - Returns null if no ads available (404).
Example
```typescript
const { client, conversationId } = useEarnLayerClient();
const ad = await client.getDisplayAd(conversationId);
if (ad === null) {
console.log('No ads available');
} else {
console.log('Got ad:', ad.title);
}
// With ad type filter
const bannerAd = await client.getDisplayAd(conversationId, 'banner');
```
Error Handling
```typescript
try {
const ad = await client.getDisplayAd(conversationId);
} catch (error) {
if (error.message.includes('Invalid conversationId')) {
console.error('conversationId cannot be empty');
} else if (error.message.includes('timeout')) {
console.error('Request timed out');
} else {
console.error('Error fetching ad:', error);
}
}
```
trackImpression(impressionId)
Tracks when an ad impression occurs. Automatically retries up to 3 times.
Signature
```typescript
trackImpression(impressionId: string): Promise<boolean>
```
Parameters
impressionId (required)
Type: string
Impression ID from the ad object.
Returns
Promise - true if tracked successfully, false if all retries failed.
Example
```typescript
const { client } = useEarnLayerClient();
const success = await client.trackImpression(ad.impressionId);
if (!success) {
console.warn('Failed to track impression after 3 retries');
}
```
trackClick(impressionId)
Tracks when a user clicks an ad. Automatically retries up to 3 times.
Signature
```typescript
trackClick(impressionId: string): Promise<boolean>
```
Parameters
impressionId (required)
Type: string
Impression ID from the ad object.
Returns
Promise - true if tracked successfully, false if all retries failed.
Example
```typescript
const { client } = useEarnLayerClient();
const success = await client.trackClick(ad.impressionId);
if (!success) {
console.warn('Failed to track click after 3 retries');
}
```
confirmHyperlinkImpressions(conversationId, messageText)
Confirms hyperlink ad impressions in an LLM response message.
Signature
```typescript
confirmHyperlinkImpressions(
conversationId: string,
messageText: string
): Promise<ConfirmResult>
```
Parameters
conversationId (required)
Type: string
Conversation ID (must be non-empty).
messageText (required)
Type: string
AI response text containing hyperlink ads (must be non-empty).
Returns
```typescript
interface ConfirmResult {
confirmed_count: number; // Number of ads confirmed
impression_ids: string[]; // Array of impression IDs
}
```
Example
```typescript
const { client, conversationId } = useEarnLayerClient();
const handleSendMessage = async (message: string) => {
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ message, conversationId })
});
const data = await response.json();
const aiResponseText = data.response;
// Confirm impressions
const result = await client.confirmHyperlinkImpressions(
conversationId,
aiResponseText
);
console.log(`Confirmed ${result.confirmed_count} ad impressions`);
};
```
Error Handling
```typescript
try {
const result = await client.confirmHyperlinkImpressions(
conversationId,
messageText
);
} catch (error) {
if (error.message.includes('Invalid conversationId')) {
console.error('conversationId is required');
} else if (error.message.includes('Invalid messageText')) {
console.error('messageText is required');
} else if (error.message.includes('timeout')) {
console.error('Request timed out after 10 seconds');
} else {
console.error('Error confirming impressions:', error);
}
}
```
Features Included in All Methods
10-second timeout with AbortController
Input validation (where applicable)
Automatic retry with exponential backoff (trackImpression, trackClick)
Detailed error messages
Automatic error logging to console
Next Steps
React Hooks
Types & Interfaces
Error Handling
# API Reference: React Hooks
sidebarTitle: “React Hooks”
icon: CodeIcon
Complete reference for all EarnLayer React hooks.
useEarnLayerClient
Main hook for accessing EarnLayer client functionality.
Usage
```typescript
function ChatComponent() {
const {
client,
conversationId,
isReady,
initializeConversation
} = useEarnLayerClient();
}
```
Returns
Property Type Description
client EarnLayerClient EarnLayer client instance
conversationId string | null Current conversation ID
isReady boolean Provider initialized status
initializeConversation () => Promise Create new conversation
Example
```typescript
function ChatPage() {
const { conversationId, initializeConversation } = useEarnLayerClient();
useEffect(() => {
// Initialize conversation on page load
initializeConversation();
}, []);
const handleNewChat = async () => {
// Create new conversation
const newId = await initializeConversation();
console.log('New conversation:', newId);
};
return (
<div>
Conversation ID: {conversationId}
<button onClick={handleNewChat}>New Chat</button>
</div>
);
}
```
Example with Demo Mode
```typescript
function ChatPage() {
const { conversationId, initializeConversation } = useEarnLayerClient();
useEffect(() => {
// Initialize with demo mode for testing
initializeConversation({
demo_mode: process.env.NODE_ENV === 'development' // Auto-enable in dev
});
}, []);
return (
<div>
Conversation ID: {conversationId}
Mode: {process.env.NODE_ENV === 'development' ? 'Demo' : 'Production'}
</div>
);
}
```
useDisplayAd
Hook for fetching and managing display ads.
Usage
```typescript
function DisplayAdComponent() {
const {
ad,
isLoading,
error,
refetch
} = useDisplayAd({
adType: 'banner'
});
}
```
Parameters
Parameter Type Default Description
adType 'hyperlink' | 'thinking' | 'banner' 'banner' Type of ad to fetch
autoFetch boolean true Automatically fetch ads
onAdFetched (ad: DisplayAd) => void undefined Callback when ad is fetched
onError (error: Error) => void undefined Error callback
Returns
Property Type Description
ad DisplayAd | null Current ad object
isLoading boolean Loading state
error Error | null Error state
refetch () => Promise Manually fetch new ad
refetchWithRefresh () => Promise Fetch next ad from queue (excludes last served ad)
Example
```typescript
function DisplayAdComponent({ messageCount }: { messageCount: number }) {
const { ad, isLoading, refetch, refetchWithRefresh } = useDisplayAd({
adType: 'banner',
onAdFetched: (ad) => {
console.log('New ad loaded:', ad.title);
}
});
// Refetch ad after each message
useEffect(() => {
if (messageCount > 0) {
refetchWithRefresh(); // Use refresh to exclude last ad
}
}, [messageCount, refetchWithRefresh]);
if (isLoading) return <div>Loading ad...</div>;
if (!ad) return null;
return (
<div>
<h3>{ad.title}</h3>
<p>{ad.description}</p>
</div>
);
}
```
useEarnLayer
Legacy hook for backward compatibility. Use useEarnLayerClient instead.
Usage
```typescript
function ChatComponent() {
const {
conversationId,
refreshAds
} = useEarnLayer();
}
```
Returns
Property Type Description
conversationId string | null Current conversation ID
refreshAds () => void Refresh all ads
Example
```typescript
function ChatWithRefresh() {
const { conversationId, refreshAds } = useEarnLayer();
const sendMessage = async (message: string) => {
// Send message logic
await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ message, conversationId })
});
// Refresh ads after message
refreshAds();
};
return (
<div>
<button onClick={() => sendMessage('Hello')}>
Send Message
</button>
</div>
);
}
```
DisplayAd Object
Structure of ad objects returned by useDisplayAd.
Properties
Property Type Description
id string Unique ad identifier
impressionId string Impression tracking ID
title string Ad title
description string | undefined Ad description
url string Click URL (includes tracking)
imageUrl string | undefined Ad image URL
adType 'hyperlink' | 'thinking' | 'banner' Ad type
source 'queue' | 'fallback' Ad source
Example
```typescript
interface DisplayAd {
id: 'ad_123';
impressionId: 'imp_456';
title: 'Asana - Project Management';
description: 'Organize your team\'s work';
url: 'https://asana.com?utm_source=earnlayer&impression_id=imp_456';
imageUrl: 'https://example.com/asana.jpg';
adType: 'banner';
source: 'queue';
}
```
Error Handling
Common Error Types
```typescript
interface EarnLayerError {
message: string;
code: string;
details?: any;
}
// Handle errors in hooks
const { ad, error } = useDisplayAd({
onError: (error: EarnLayerError) => {
console.error('Ad fetch failed:', error.message);
switch (error.code) {
case 'RATE_LIMITED':
// Handle rate limiting
break;
case 'INVALID_API_KEY':
// Handle invalid API key
break;
case 'QUOTA_EXCEEDED':
// Handle quota exceeded
break;
}
}
});
```
Error Recovery
```typescript
function ResilientDisplayAd() {
const [retryCount, setRetryCount] = useState(0);
const maxRetries = 3;
const { ad, error, refetch } = useDisplayAd({
onError: (error) => {
if (retryCount < maxRetries) {
setTimeout(() => {
setRetryCount(prev => prev + 1);
refetch();
}, 1000 * retryCount);
}
}
});
if (error && retryCount >= maxRetries) {
return <div>Ad temporarily unavailable</div>;
}
return ad ? <DisplayAdComponent ad={ad} /> : <div>Loading...</div>;
}
```
Performance Optimization
Memoization
```typescript
const DisplayAdComponent = memo(() => {
const { ad } = useDisplayAd({ adType: 'banner' });
const memoizedAd = useMemo(() => {
if (!ad) return null;
return (
<div>
<h3>{ad.title}</h3>
</div>
);
}, [ad]);
return memoizedAd;
});
```
Conditional Rendering
```typescript
function ConditionalAd({ userType }: { userType: string }) {
// Only show ads to free users
if (userType === 'premium') {
return null;
}
return <DisplayAdComponent />;
}
```
Lazy Loading
```typescript
const DisplayAd = lazy(() =>
import('@earnlayer/sdk/react').then(m => ({ default: m.DisplayAd }))
);
function ChatPage() {
return (
<Suspense fallback={<div>Loading ad...</div>}>
<DisplayAd />
</Suspense>
);
}
```
Best Practices
1. Always Handle Loading States
```typescript
function DisplayAdWithLoading() {
const { ad, isLoading, error } = useDisplayAd({ adType: 'banner' });
if (isLoading) return <div>Loading ad...</div>;
if (error) return <div>Ad unavailable</div>;
if (!ad) return null;
return <DisplayAdComponent ad={ad} />;
}
```
2. Use Conversation Context
```typescript
function ContextualAd() {
const { conversationId } = useEarnLayerClient();
// Only show ads when conversation is active
if (!conversationId) return null;
return <DisplayAdComponent />;
}
```
3. Implement Proper Cleanup
```typescript
function AdWithCleanup() {
const { ad, refetch } = useDisplayAd({ adType: 'banner' });
useEffect(() => {
const interval = setInterval(refetch, 30000);
return () => clearInterval(interval);
}, [refetch]);
return <DisplayAdComponent ad={ad} />;
}
```
4. Track Performance
```typescript
function TrackedDisplayAd() {
const { ad, isLoading } = useDisplayAd({
adType: 'banner',
onAdFetched: (ad) => {
// Track ad performance
analytics.track('ad_impression', {
ad_id: ad.id,
source: ad.source
});
}
});
return <DisplayAdComponent ad={ad} />;
}
```
Next Steps
Display Ads Component - Component documentation
Thinking Ads Component - Loading state ads
Advanced Display Ad Implementation - Production-ready patterns
Troubleshooting - Common issues and solutions
Quick Start Guide - Complete setup guide
# API Reference: Types
TypeScript type definitions for EarnLayer SDK.
DisplayAd
```typescript
interface DisplayAd {
id: string;
impressionId: string;
title: string;
description?: string;
url: string;
imageUrl?: string;
adType: 'hyperlink' | 'thinking' | 'banner';
source: 'queue' | 'fallback';
}
```
Important: Always use ad.url directly - it includes backend redirect for automatic tracking.
Conversation
```typescript
interface Conversation {
conversation_id: string;
creator_id: string;
ad_settings: object;
status: string;
created_at: string;
}
```
EarnLayerConfig
```typescript
interface EarnLayerConfig {
proxyBaseUrl?: string;
debug?: boolean;
}
```
UseDisplayAdOptions
```typescript
interface UseDisplayAdOptions {
adType?: 'hyperlink' | 'thinking' | 'banner';
autoFetch?: boolean;
debug?: boolean;
onAdFetched?: (ad: DisplayAd) => void;
onError?: (error: Error) => void;
}
```
InitializeConversationOptions
```typescript
interface InitializeConversationOptions {
visitorId?: string;
adTypes?: ('hyperlink' | 'thinking' | 'banner')[];
frequency?: 'low' | 'normal' | 'high';
demo_mode?: boolean;
}
```
Field Descriptions:
- visitorId - Custom visitor ID for tracking
- adTypes - Types of ads to enable (['hyperlink', 'thinking', 'banner'] by default)
- frequency - Ad frequency setting ('normal' by default)
- demo_mode - When true, shows all demo ads regardless of partnerships setup. Use for testing only. (false by default)
ConfirmImpressionsResult
```typescript
interface ConfirmImpressionsResult {
confirmed_count: number;
impression_ids: string[];
}
```
Next Steps
React Hooks
Client Methods
# Architecture Overview
Understanding how EarnLayer components work together.
System Architecture
User Question
↓
Your Chat Route → MCP Server (gets hyperlink ads)
↓ ↓
LLM Response ← ← ← ← ← ← ← ← ← Ads
↓
Browser (displays response with hyperlink ads)
↓
EarnLayer SDK → Your Proxy → EarnLayer API (tracks clicks, gets display ads)
Two Main Components
1. MCP Server (Phase 1A)
Purpose: Provides hyperlink ads to your LLM via function calling
Location: Hosted by EarnLayer at https://mcp.earnlayerai.com/mcp
Used in: Your chat API route only
Environment variable: EARNLAYER_MCP_URL
How it works:
Your LLM calls the MCP tool when user asks about products
MCP returns relevant sponsored ads
LLM includes ads in response with clickable links
2. EarnLayer SDK (Phase 1B & Phase 2)
Purpose: Client-side SDK for tracking impressions and fetching display ads
Location: Installed in your project via npm install @earnlayer/sdk
Used in: React components and proxy endpoints
Environment variable: EARNLAYER_API_KEY (server-side only)
How it works:
Browser requests ads via hooks (useDisplayAd)
Request goes to your proxy endpoint
Proxy calls EarnLayer API with API key
Ads returned to browser
Data Flow
Hyperlink Ads (Phase 1A)
1. User: "What VPN should I use?"
2. Your API → OpenAI/Gemini (with MCP tool)
3. LLM → MCP Server: "Get VPN ads"
4. MCP → LLM: [NordVPN, ExpressVPN, ...]
5. LLM → Your API: "I recommend [NordVPN](url)..."
6. Your API → Browser: Formatted response
7. User sees: Response with clickable sponsored links
Display Ads (Phase 2)
1. Browser: useDisplayAd() hook
2. Hook → Proxy: GET /api/earnlayer/displayad/...
3. Proxy → EarnLayer API (with API key)
4. EarnLayer → Proxy: Ad object
5. Proxy → Browser: Ad object (no API key)
6. Browser: Renders ad component
7. User clicks → Backend redirect → Advertiser site
Security Architecture
API Key Protection
Browser (no API key)
↓
Your Proxy Endpoint (has API key)
↓
EarnLayer API (validates API key)
API keys never leave your server.
Click Tracking
User clicks ad.url
↓
Backend redirect: api.earnlayerai.com/redirect?...
↓
Track click server-side
↓
Redirect to advertiser
Tracking happens server-side for security and accuracy.
Next Steps
Security Overview
Phase 1A Guide
Phase 1B Guide
# Support
Get help and connect with other EarnLayer developers.
Contact Support
Email
support@earnlayerai.com
For technical support, integration help, or general questions.
Security Issues
support@earnlayerai.com
For reporting security vulnerabilities (please do not create public issues).
Community
NPM Package
@earnlayer/sdk
Install and use the official EarnLayer SDK.
Dashboard
EarnLayer Dashboard
app.earnlayerai.com/dashboard
Monitor:
Impressions and clicks
Revenue and earnings
Conversation analytics
Ad performance
Get API Key
app.earnlayerai.com/api-keys
Create and manage your API keys.
Documentation
This documentation site covers everything you need to integrate EarnLayer.
Quick Links:
Getting Started
Phase 1A Guide
API Reference
Troubleshooting
Response Times
Email Support:
We aim to respond within 24 hours on business days
Critical issues are prioritized
NPM Package:
Official SDK with full documentation
Regular updates and maintenance
Before Contacting Support
Please check:
Common Issues - Most problems are solved here
Error Messages - Specific error solutions
Check NPM package documentation - Most questions are answered there
When contacting support, include:
SDK version
Framework/LLM (Next.js, OpenAI, etc.)
Error messages from console
Steps to reproduce issue
License
Proprietary - All Rights Reserved
Copyright © 2025 EarnLayer, Inc.
# Conversation Management
Control when to create new conversations for optimal ad targeting.
When to Create Conversations
Users control conversation lifecycle:
const { conversationId, initializeConversation } = useEarnLayerClient();
// Create conversation on page load (recommended)
useEffect(() => {
initializeConversation();
}, []);
// Create new conversation on "New Chat" button
const handleNewChat = async () => {
const newConversationId = await initializeConversation();
// This resets the ad queue for fresh contextual ads
};
Best Practices
On Page Load
Always initialize a conversation when the chat page loads:
function ChatPage() {
const { initializeConversation } = useEarnLayerClient();
const hasInitialized = useRef(false);
useEffect(() => {
if (\!hasInitialized.current) {
hasInitialized.current = true;
initializeConversation();
}
}, []);
return Your chat UI;
}
On “New Chat” Button
Reset the conversation when user starts a new topic:
const handleNewChat = async () => {
await initializeConversation();
setMessages([]); // Clear message history
// Fresh conversation = fresh contextual ads
};
Custom Options
// Testing with demo mode
const conversation = await initializeConversation({
demo_mode: true, // Show all demo ads
visitorId: 'user_123',
adTypes: ['banner', 'thinking'],
frequency: 'low'
});
// Production mode
const conversation = await initializeConversation({
demo_mode: false, // Show only real ads with partnerships
visitorId: 'user_123',
adTypes: ['hyperlink', 'thinking', 'banner'],
frequency: 'normal'
});
→ Learn more about Demo Mode
Next Steps
Phase 1B Guide
API Reference
integrations
EarnLayer works with any LLM that supports MCP (Model Context Protocol).
Choose Your Provider
OpenAI
Best for: Production-ready apps with OpenAI’s Responses API
Full MCP support
Streaming responses
Multi-turn conversations
→ Start with OpenAI
Google Gemini
Best for: Cost-effective solutions with Google’s latest models
Native MCP integration
Fast response times
Multi-modal capabilities
→ Start with Gemini
Other LLMs
Works with: Any MCP-compatible provider
Supported providers include:
Claude (Anthropic)
Llama models
Custom LLM deployments
Any provider supporting MCP tool calling
→ Start with Other Provider
Need integration help?
Email: support@earnlayerai.com
NPM: @earnlayer/sdk
What is MCP?
Model Context Protocol (MCP) is a standard for tool calling in LLMs. EarnLayer uses MCP to:
Provide ad recommendations to your LLM
Track which ads are shown to users
Enable contextual ad targeting
All without changing your existing chat implementation.
Quick Comparison
Feature OpenAI Gemini Other
MCP Support Native Native Varies
Setup Time 5 min 5 min 5-15 min
Streaming Yes Yes Varies
Get Started Phase 1A Phase 1A Phase 1A
Next Steps
Ready to integrate? Choose your provider above and follow the step-by-step guide.
All providers follow the same three-phase approach:
Phase 1A - Add hyperlink ads (5 min)
Phase 1B - Production setup (10 min)
Phase 2 - Display ads (5-10 min)
security
EarnLayer is secure by design with multiple layers of protection.
Key Security Features
API Keys Never Exposed to Browser
All API calls go through your Next.js backend proxy:
// app/api/earnlayer/[...slug]/route.ts
const handler = createEarnLayerProxy({
apiKey: process.env.EARNLAYER_API_KEY\! // Server-side only
});
Your EARNLAYER_API_KEY stays in .env.local and never reaches the browser.
Server-to-Server Authentication
The proxy endpoint handles JWT authentication automatically:
Browser requests ad → Your proxy endpoint
Your proxy → EarnLayer API (with API key)
EarnLayer returns JWT token
Proxy forwards response → Browser (no API key)
No CORS Configuration Needed
All requests are same-origin (your domain):
Browser → yoursite.com/api/earnlayer → EarnLayer API
No cross-origin requests = no CORS vulnerabilities.
Click Tracking via Backend Redirects
Ad URLs include backend redirects for secure tracking:
```typescript
// ad.url format:
https://api.earnlayerai.com/redirect?impression_id=imp_123&target=https://example.com
```
Clicks are tracked server-side before redirecting to the advertiser.
Environment Variables
Required Variables
# .env.local (NEVER commit this file)
# Server-side only (used in proxy endpoint)
EARNLAYER_API_KEY=el_your_api_key_here
# Server-side only (used in chat API route for MCP)
EARNLAYER_MCP_URL=https://mcp.earnlayerai.com/mcp
# Your LLM provider keys (server-side only)
OPENAI_API_KEY=sk_your_openai_key_here
# OR
GOOGLE_API_KEY=your_google_api_key_here
Where Each Variable is Used
Variable Used In Exposed to Browser?
EARNLAYER_API_KEY Proxy endpoint, MCP server No
EARNLAYER_MCP_URL Chat API route No
OPENAI_API_KEY Chat API route No
GOOGLE_API_KEY Chat API route No
All keys are server-side only.
Best Practices
DO
Store API keys in .env.local
Add .env.local to .gitignore
Use the proxy endpoint for all SDK calls
Keep SDK updated for security patches
Rotate API keys periodically
DON’T
Hardcode API keys in code
Commit .env.local to git
Use API keys in client-side code
Share API keys publicly
Expose API keys in browser console logs
Verifying Security
Check 1: No API Keys in Browser
Open browser DevTools → Network tab → Check any /api/earnlayer/* requests:
Request Headers:
Content-Type: application/json
Request Body:
{ "conversationId": "conv_123" }
No x-api-key or EARNLAYER_API_KEY should appear in browser requests.
Check 2: Environment Variables Not in Bundle
Check your built JavaScript:
npm run build
Search the build output:
grep -r "el_" .next/
No API keys should appear in the build output.
Check 3: Proxy Endpoint Working
Test the proxy endpoint:
curl -X POST http://localhost:3000/api/earnlayer/initialize \
-H "Content-Type: application/json"
Should return {"conversation_id": "..."} without errors.
Security Checklist
Before deploying to production:
All API keys in .env.local (not .env)
.env.local added to .gitignore
No API keys hardcoded in source code
Proxy endpoint created and working
All SDK calls go through proxy
No API keys visible in browser DevTools
Environment variables set in production hosting platform
Reporting Security Issues
Found a security vulnerability? Email us:
support@earnlayerai.com
Please do not create public issues for security vulnerabilities.
Next Steps
Environment Variables
Best Practices
Phase 1B Guide
troubleshooting
Solutions to frequently encountered problems.
No Ads Showing
Check API Key
Verify EARNLAYER_API_KEY is set in .env.local:
cat .env.local | grep EARNLAYER
Should show:
EARNLAYER_API_KEY=el_...
EARNLAYER_MCP_URL=https://mcp.earnlayerai.com/mcp
Restart Dev Server
Environment variables require a full restart:
# Stop server (Ctrl+C)
npm run dev
Check Browser Console
Look for errors in browser DevTools console:
[EarnLayer SDK] Failed to initialize conversation: ...
Verify Proxy Endpoint
Test the proxy:
curl -X POST http://localhost:3000/api/earnlayer/initialize
Should return:
{"conversation_id": "conv_..."}
AI Not Calling MCP Tool
Use Specific Questions
“What VPN should I use?”
“Best project management tools?”
Avoid: “Hello”
Avoid: “How are you?”
Check System Instructions
Verify SYSTEM_INSTRUCTIONS is included in your prompts.
Verify MCP Configuration
Check headers are correct:
headers: {
'x-api-key': EARNLAYER_API_KEY,
'x-conversation-id': conversationId
}
Display Ads Not Showing
Check Conversation ID
const { conversationId } = useEarnLayerClient();
console.log('Conversation ID:', conversationId);
Should not be null.
Check Network Tab
DevTools → Network → Filter by earnlayer:
Look for /api/earnlayer/displayad/... requests.
Status should be 200 OK.
Try Manual Refetch
const { refetch } = useDisplayAd({});
// In button or useEffect
refetch();
Not Seeing Enough Ads / Limited Ad Inventory
Cause
You’re in production mode without partnerships configured, or demo mode is disabled during testing.
Solution
For testing: Enable demo mode to see all demo ads
initializeConversation({ demo_mode: true })
For production: Set up partnerships with advertisers in the EarnLayer App
→ Learn more about Demo Mode
Common Error Messages
”Invalid EarnLayer API key”
Cause: Invalid or expired API key
Solution: Verify EARNLAYER_API_KEY starts with el_
Get new key at app.earnlayerai.com/api-keys
”API rate limit exceeded”
Cause: Too many requests
Solution: Wait 1 minute, or contact support for higher limits
”Cannot fetch display ad: conversationId not available”
Cause: Trying to fetch ads before initializeConversation()
Solution: Call initializeConversation() on mount
”Failed to initialize conversation”
Cause: Network issue or authentication problem
Solution: Check API key, network connection, console for details
Module Not Found Errors
"@earnlayer/sdk not found"
npm install @earnlayer/sdk
"openai not found"
npm install openai
"@google/genai not found"
npm install @google/genai @modelcontextprotocol/sdk
Integration Troubleshooting Guide
Complete solutions for common integration issues when implementing EarnLayer SDK with Vercel AI SDK and other frameworks.
Vercel AI SDK Integration (streamText)
If you're using Vercel AI SDK's streamText function, you need a custom tool wrapper pattern. The SDK doesn't support MCP tools directly.
Problem: streamText doesn't support MCP tools natively
Solution: Create a custom tool wrapper that wraps MCP calls
Step 1: Install Required Dependencies
npm install @earnlayer/sdk @modelcontextprotocol/sdk ai zod
Step 2: Create Custom MCP Tool Wrapper
CREATE: lib/ai/tools/earnlayer-ads.ts
```typescript
import { tool } from 'ai';
import { z } from 'zod';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
const EARNLAYER_MCP_URL = process.env.EARNLAYER_MCP_URL!;
const EARNLAYER_API_KEY = process.env.EARNLAYER_API_KEY!;
// Factory function to create tool with context
export function createEarnlayerAdsTool(options: {
conversationId?: string;
demoMode?: boolean;
}) {
return tool({
description: 'IMPORTANT: Search for sponsored product recommendations. You MUST call this tool whenever a user asks about products, services, tools, software, apps, websites, or anything that could be purchased, subscribed to, or used. This tool finds relevant sponsored links to include in your response.',
inputSchema: z.object({
query: z.string().describe('The user\'s question or search query about products/services. Extract the main product/service topic from their question.'),
}),
execute: async ({ query }: { query: string }) => {
let client: Client | null = null;
try {
const headers: Record<string, string> = {
'x-api-key': EARNLAYER_API_KEY,
};
if (options.conversationId) {
headers['x-conversation-id'] = options.conversationId;
}
if (options.demoMode !== undefined) {
headers['x-demo-mode'] = options.demoMode ? 'true' : 'false';
}
const transport = new StreamableHTTPClientTransport(
new URL(EARNLAYER_MCP_URL),
{ requestInit: { headers } }
);
client = new Client(
{ name: 'earnlayer-client', version: '1.0.0' },
{ capabilities: {} }
);
await client.connect(transport);
// MCP expects queries as array and conversation_id in arguments
const mcpArguments: Record<string, any> = {
queries: [query], // Array format required
};
if (options.conversationId) {
mcpArguments.conversation_id = options.conversationId;
}
const result = await client.callTool({
name: 'earnlayer_content_ads_search',
arguments: mcpArguments
});
await client.close();
client = null;
// Parse response - MCP returns { results: [{ query, hyperlink_ads: [...] }], summary: {...} }
if (result.content && result.content[0]?.type === 'text') {
const content = JSON.parse(result.content[0].text);
const hyperlinkAds = content.results?.[0]?.hyperlink_ads || [];
return {
ads: hyperlinkAds,
query,
summary: content.summary
};
}
return { ads: [], query };
} catch (error) {
console.error('EarnLayer MCP error:', error);
if (client) {
try {
await client.close();
} catch (e) {
// Ignore close errors
}
}
return { ads: [], query, error: 'Failed to fetch ads' };
}
},
});
}
```
Key Points:
- Use inputSchema (not parameters) for Vercel AI SDK tool definition
- MCP arguments must be: { queries: [query], conversation_id?: string }
- Response parsing: content.results?.[0]?.hyperlink_ads (not content.ads)
- Always close MCP client in finally block or catch
Step 3: Update Chat Route
MODIFY: app/(chat)/api/chat/route.ts
```typescript
import { createEarnlayerAdsTool } from '@/lib/ai/tools/earnlayer-ads';
import { DEFAULT_MCP_INSTRUCTIONS } from '@earnlayer/sdk/mcp';
import { streamText } from 'ai';
export async function POST(request: Request) {
const requestBody = await request.json();
const {
id,
message,
selectedChatModel,
conversationId, // Extract from request
} = requestBody;
// Create tool with context
const earnlayerAdsSearch = createEarnlayerAdsTool({
conversationId: conversationId ?? undefined,
demoMode: process.env.NODE_ENV === 'development'
});
const result = streamText({
model: myProvider.languageModel(selectedChatModel),
system: systemPrompt({ selectedChatModel }) + '\n\n' + DEFAULT_MCP_INSTRUCTIONS + '\n\nIMPORTANT: When users ask about products, services, tools, software, or anything purchasable, you MUST call the earnlayerAdsSearch tool FIRST before answering. Include relevant sponsored links in markdown format [Product Name](url) in your response.',
messages: convertToModelMessages(uiMessages),
experimental_activeTools: [
"getWeather",
"earnlayerAdsSearch", // ADD: Must be in both arrays
],
tools: {
getWeather,
earnlayerAdsSearch, // ADD: Tool instance
},
// ... rest of config
});
return new Response(result.toDataStreamResponse());
}
```
Critical: Tool must be in BOTH experimental_activeTools array AND tools object
Schema Validation Errors
Problem: "Invalid schema" or "conversationId is required" errors
Solution: Update request schema to allow optional conversationId
MODIFY: app/(chat)/api/chat/schema.ts
```typescript
import { z } from "zod";
export const postRequestBodySchema = z.object({
id: z.string().uuid(),
message: z.object({
id: z.string().uuid(),
role: z.enum(["user"]),
parts: z.array(partSchema),
}),
selectedChatModel: z.enum(["chat-model", "chat-model-reasoning"]),
selectedVisibilityType: z.enum(["public", "private"]),
conversationId: z.string().optional().nullable(), // ADD: Optional conversationId
});
```
Why: conversationId may be null initially before initializeConversation() completes. Schema must allow null/undefined to prevent validation errors.
Conditional conversationId in Request Body
Problem: Sending null conversationId causes validation errors
Solution: Conditionally include conversationId only if it exists
MODIFY: components/chat.tsx (or your chat component)
```typescript
import { useEarnLayerClient } from "@earnlayer/sdk/react";
import { useChat } from "@ai-sdk/react";
function ChatComponent() {
const { conversationId, initializeConversation } = useEarnLayerClient();
// Initialize on mount
useEffect(() => {
if (!hasInitialized.current) {
hasInitialized.current = true;
initializeConversation({
demoMode: process.env.NODE_ENV === 'development' // Note: camelCase
});
}
}, [initializeConversation]);
const { sendMessage } = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
prepareSendMessagesRequest(request) {
return {
body: {
id: request.id,
message: request.messages.at(-1),
selectedChatModel: currentModelId,
selectedVisibilityType: visibilityType,
...(conversationId && { conversationId }), // ADD: Only include if exists
...request.body,
},
};
},
}),
// ... rest of config
});
}
```
Why: Spread operator with conditional prevents sending null/undefined values that would fail schema validation.
Message Parts Extraction for Impression Confirmation
Problem: confirmHyperlinkImpressions expects string, but message object has parts array
Solution: Extract text from message.parts array
MODIFY: components/chat.tsx onFinish callback
```typescript
import { useEarnLayerClient } from "@earnlayer/sdk/react";
function ChatComponent() {
const { client, conversationId } = useEarnLayerClient();
const { messages, sendMessage } = useChat({
// ... config
onFinish: async ({ message }) => {
// Confirm hyperlink impressions after AI response
if (conversationId && message && message.role === 'assistant') {
// Extract text content from message parts array
const textContent = message.parts
?.filter((part: any) => part.type === 'text')
.map((part: any) => part.text)
.join('\n') || '';
if (textContent && client) {
try {
const result = await client.confirmHyperlinkImpressions(
conversationId,
textContent
);
console.log(`Confirmed ${result.confirmed_count} impressions`);
} catch (error) {
console.error('Failed to confirm impressions:', error);
}
}
}
},
});
}
```
Why: Vercel AI SDK messages use parts array structure. Extract all text parts and join them.
Provider Setup Location
Problem: Where to wrap with EarnLayerProvider?
Solution: Wrap in root layout, not page component
MODIFY: app/layout.tsx (root layout)
```typescript
import { EarnLayerProvider } from "@earnlayer/sdk/react";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ThemeProvider>
<SessionProvider>
<EarnLayerProvider>
{children}
</EarnLayerProvider>
</SessionProvider>
</ThemeProvider>
</body>
</html>
);
}
```
Why: Root layout ensures provider is available to all pages and components. Wrapping in page component limits availability.
demoMode Parameter Naming
Problem: Inconsistent naming between demoMode and demo_mode
Solution: Use camelCase (demoMode) consistently
Correct Usage:
```typescript
// Tool creation
const earnlayerAdsSearch = createEarnlayerAdsTool({
conversationId: conversationId ?? undefined,
demoMode: process.env.NODE_ENV === 'development' // camelCase
});
// Conversation initialization
initializeConversation({
demoMode: process.env.NODE_ENV === 'development' // camelCase
});
```
Note: SDK uses camelCase (demoMode) in TypeScript/JavaScript. MCP headers use snake_case (x-demo-mode) but that's handled internally.
System Prompt Pattern
Problem: Tool not being called or ads not appearing
Solution: Use DEFAULT_MCP_INSTRUCTIONS plus explicit instructions
MODIFY: app/(chat)/api/chat/route.ts
```typescript
import { DEFAULT_MCP_INSTRUCTIONS } from '@earnlayer/sdk/mcp';
const result = streamText({
model: myProvider.languageModel(selectedChatModel),
system: systemPrompt({ selectedChatModel }) + '\n\n' + DEFAULT_MCP_INSTRUCTIONS + '\n\nIMPORTANT: When users ask about products, services, tools, software, or anything purchasable, you MUST call the earnlayerAdsSearch tool FIRST before answering. Include relevant sponsored links in markdown format [Product Name](url) in your response.',
// ... rest
});
```
Why: DEFAULT_MCP_INSTRUCTIONS provides base instructions. Additional explicit instructions ensure tool is called for product-related queries.
experimental_activeTools Configuration
Problem: Tool defined but LLM never calls it
Solution: Add tool to BOTH experimental_activeTools and tools
```typescript
const result = streamText({
// ... config
experimental_activeTools: [
"getWeather",
"earnlayerAdsSearch", // REQUIRED: Tool name as string
],
tools: {
getWeather,
earnlayerAdsSearch, // REQUIRED: Tool instance
},
});
```
Why: experimental_activeTools tells LLM which tools are available. tools object provides the actual tool implementations. Both are required.
Artifact View Integration
Problem: Thinking ads not showing in artifact/preview view
Solution: Add ThinkingAdComponent to artifact messages component
MODIFY: components/artifact-messages.tsx
```typescript
import { ThinkingAdComponent } from "./thinking-ad";
function ArtifactMessages({ status, messages }: Props) {
return (
<div className="chat-scroll-container">
{messages.map((message) => (
<PreviewMessage message={message} />
))}
{/* ADD: Thinking ad for artifact view */}
{status === "submitted" && (
<ThinkingAdComponent status={status} />
)}
</div>
);
}
```
Why: Artifact view has separate message rendering. Thinking ad must be added explicitly.
Quick Reference Checklist
Before deploying, verify:
Setup
- EarnLayerProvider wraps app in app/layout.tsx
- Proxy endpoint created at app/api/earnlayer/[...slug]/route.ts
- Environment variables set: EARNLAYER_API_KEY, EARNLAYER_MCP_URL
Chat Route Integration
- Custom tool wrapper created: lib/ai/tools/earnlayer-ads.ts
- Tool uses inputSchema (not parameters)
- Tool added to experimental_activeTools array
- Tool added to tools object
- System prompt includes DEFAULT_MCP_INSTRUCTIONS
- conversationId extracted from request body
- Tool created with conversationId and demoMode context
Schema
- conversationId: z.string().optional().nullable() in schema
Client Component
- initializeConversation() called on mount with demoMode
- conversationId conditionally included in request body
- onFinish callback extracts text from message.parts
- confirmHyperlinkImpressions called with extracted text
Display Ads
- ThinkingAdComponent uses manual fetch (autoFetch: false)
- DisplayAdComponent uses assistantMessageCount dependency
- Both components handle status conditions correctly
Common Integration Patterns
Pattern 1: Vercel AI SDK with streamText
// Tool wrapper
const tool = createEarnlayerAdsTool({ conversationId, demoMode });
// In streamText
experimental_activeTools: ["earnlayerAdsSearch"],
tools: { earnlayerAdsSearch: tool },
Pattern 2: Message Parts Extraction
const textContent = message.parts
?.filter(part => part.type === 'text')
.map(part => part.text)
.join('\n') || '';
Pattern 3: Conditional conversationId
// Request body
...(conversationId && { conversationId })
// Tool creation
conversationId: conversationId ?? undefined
Pattern 4: Demo Mode Configuration
// Tool creation
demoMode: process.env.NODE_ENV === 'development'
// Conversation initialization
demoMode: process.env.NODE_ENV === 'development'
Debugging Tips
1. Enable Debug Logging
// In tool wrapper
console.log('[EarnLayer] Tool called with query:', query);
console.log('[EarnLayer] Conversation ID:', options.conversationId);
console.log('[EarnLayer] MCP arguments:', mcpArguments);
console.log('[EarnLayer] Response:', content);
2. Check Tool Registration
// Verify tool is in both places
console.log('Active tools:', experimental_activeTools);
console.log('Tools object keys:', Object.keys(tools));
3. Verify conversationId Flow
// In chat component
console.log('Conversation ID:', conversationId);
// In chat route
console.log('Received conversationId:', conversationId);
4. Test MCP Connection
// In tool wrapper, add connection test
try {
await client.connect(transport);
console.log('[EarnLayer] MCP connection successful');
} catch (error) {
console.error('[EarnLayer] MCP connection failed:', error);
}
5. Check Response Parsing
// Verify response structure
console.log('[EarnLayer] Raw response:', result.content);
console.log('[EarnLayer] Parsed content:', content);
console.log('[EarnLayer] Hyperlink ads:', hyperlinkAds);
Next Steps
Phase 1A Guide - Complete setup
Phase 1B Guide - Production configuration
Advanced Display Ad Implementation - Production patterns
Troubleshooting - Common issues
Support - Get help