Skip to main content

Smart Summaries

Use AI completion endpoints to generate text summaries, extract structured insights, and create interactive streaming experiences directly in your app.

What You'll Build

A dashboard that:

  • Auto-generates executive summaries using _completion
  • Extracts structured insights using _object
  • Streams interactive chat responses using _chat
  • Handles defensive parsing for Haiku-class models

Prerequisites

Example Prompts for Claude Code

Add executive summary:

"Add an 'Executive Summary' panel that auto-generates a one-paragraph summary of the dashboard metrics using the /api/models/go_everyday/_completion endpoint. Generate it when data loads. Add a Regenerate button. Update informer.yaml to grant access to the completion endpoint."

Extract structured insights:

"Add a 'Key Insights' panel that calls the /api/models/go_everyday/_object endpoint to extract 3 key insights from the data. Return them as JSON with title and description fields. Include defensive parsing in case the AI response doesn't match the schema exactly."

Add interactive chat:

"Add an 'Ask About This Data' panel with a text input and chat message display. When the user sends a message, call the /api/models/go_everyday/_chat endpoint and stream the response token-by-token. Pass the dashboard metrics as context."

Improve the AI quality:

"Update the insights prompt to explicitly ask for specific analysis: trends, anomalies, and category comparisons."

"Add reinforcement to the _object schema by including a JSON example in the prompt itself."

AI Completion Endpoints

Informer provides three AI endpoints for direct integration:

EndpointResponseToolsUse Case
_chatSSE streamYesInteractive AI with tool calling
_completionSSE streamNoSimple text generation
_objectJSONNoStructured data extraction

All three use the go_everyday model slug.

Data Access Configuration

Update informer.yaml:

access:
datasets:
- admin:orders

apis:
- POST /api/models/go_everyday/_completion
- POST /api/models/go_everyday/_object
- POST /api/models/go_everyday/_chat

What Claude Will Generate

When you ask Claude to add AI completion features, it will implement the three endpoint patterns (_completion, _object, _chat) with proper streaming and error handling. Here's what the generated code looks like:

HTML for AI Panels

Claude will add panels for summaries, insights, and chat:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sales Dashboard</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="dashboard">
<header>
<h1>Sales Dashboard</h1>
<p class="subtitle">AI-powered insights</p>
</header>

<div class="metrics">
<div class="metric-card">
<span class="metric-value" id="totalRevenue">--</span>
<span class="metric-label">Total Revenue</span>
</div>
<div class="metric-card">
<span class="metric-value" id="orderCount">--</span>
<span class="metric-label">Orders</span>
</div>
<div class="metric-card">
<span class="metric-value" id="avgOrder">--</span>
<span class="metric-label">Avg Order</span>
</div>
<div class="metric-card">
<span class="metric-value" id="growth">--</span>
<span class="metric-label">Growth</span>
</div>
</div>

<!-- Executive Summary Panel -->
<div class="panel">
<div class="panel-header">
<h2>Executive Summary</h2>
<button id="generateSummary" class="btn-small">Regenerate</button>
</div>
<div id="summary" class="summary-text">Loading...</div>
</div>

<!-- Structured Insights Panel -->
<div class="panel">
<div class="panel-header">
<h2>Key Insights</h2>
<button id="extractInsights" class="btn-small">Refresh</button>
</div>
<div id="insights" class="insights-list">Loading...</div>
</div>

<!-- Interactive Chat Panel -->
<div class="panel panel-wide">
<div class="panel-header">
<h2>Ask About This Data</h2>
</div>
<div id="chatMessages" class="chat-messages"></div>
<div class="chat-input">
<input type="text" id="chatPrompt" placeholder="Ask a question about the data..." />
<button id="sendChat" class="btn-primary">Send</button>
</div>
</div>
</div>

<script type="module" src="main.js"></script>
</body>
</html>

AI Panel Styles

Claude will add styling for the AI-powered panels:

/* ... previous styles ... */

.btn-small {
padding: 6px 12px;
background: var(--bg);
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}

.btn-small:hover {
background: var(--primary);
color: white;
border-color: var(--primary);
}

.summary-text {
line-height: 1.7;
color: var(--text-muted);
}

.insights-list {
display: flex;
flex-direction: column;
gap: 12px;
}

.insight-card {
padding: 16px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
}

.insight-card h3 {
font-size: 15px;
font-weight: 600;
margin-bottom: 6px;
}

.insight-card p {
font-size: 14px;
color: var(--text-muted);
line-height: 1.6;
}

.chat-messages {
min-height: 200px;
max-height: 400px;
overflow-y: auto;
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}

.chat-message {
padding: 12px 16px;
border-radius: 12px;
max-width: 80%;
}

.chat-message.user {
align-self: flex-end;
background: var(--primary);
color: white;
}

.chat-message.assistant {
align-self: flex-start;
background: var(--bg);
border: 1px solid var(--border);
}

.chat-input {
display: flex;
gap: 8px;
}

.chat-input input {
flex: 1;
padding: 10px 14px;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
}

.chat-input input:focus {
outline: none;
border-color: var(--primary);
}

@media print {
.btn-small { display: none; }
.chat-input { display: none; }
}

AI Endpoint Implementation

Claude will implement all three AI endpoints with streaming support:

window.informerReady = false;

const theme = window.__INFORMER__?.theme || 'light';
document.documentElement.setAttribute('data-theme', theme);

const ORDERS_DATASET = 'admin:orders';
let dashboardData = {};

async function init() {
try {
// Load data
await loadData();

// Generate initial AI content
await generateSummary();
await extractInsights();

// Wire up controls
document.getElementById('generateSummary').addEventListener('click', generateSummary);
document.getElementById('extractInsights').addEventListener('click', extractInsights);
document.getElementById('sendChat').addEventListener('click', sendChatMessage);
document.getElementById('chatPrompt').addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendChatMessage();
});

window.informerReady = true;
} catch (err) {
console.error('Initialization failed:', err);
}
}

async function loadData() {
const response = await fetch(`/api/datasets/${ORDERS_DATASET}/_search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: { match_all: {} },
size: 0,
aggs: {
totalRevenue: { sum: { field: 'amount' } },
orderCount: { value_count: { field: 'id' } },
avgOrder: { avg: { field: 'amount' } },
byCategory: {
terms: { field: 'category', size: 5 },
aggs: { revenue: { sum: { field: 'amount' } } }
}
}
})
});

const result = await response.json();
const aggs = result.aggregations;

dashboardData = {
totalRevenue: aggs.totalRevenue.value,
orderCount: aggs.orderCount.value,
avgOrder: aggs.avgOrder.value,
topCategories: aggs.byCategory.buckets.map(b => ({
category: b.key,
revenue: b.revenue.value,
count: b.doc_count
}))
};

// Update UI
document.getElementById('totalRevenue').textContent = formatCurrency(dashboardData.totalRevenue);
document.getElementById('orderCount').textContent = dashboardData.orderCount.toLocaleString();
document.getElementById('avgOrder').textContent = formatCurrency(dashboardData.avgOrder);
document.getElementById('growth').textContent = '+12.5%'; // Mock
}

// ============================================================================
// TEXT COMPLETION - Executive Summary
// ============================================================================

async function generateSummary() {
const summaryEl = document.getElementById('summary');
summaryEl.textContent = 'Generating summary...';

try {
const prompt = `Write a one-paragraph executive summary of this sales data:

Total Revenue: ${formatCurrency(dashboardData.totalRevenue)}
Orders: ${dashboardData.orderCount}
Average Order: ${formatCurrency(dashboardData.avgOrder)}
Top Categories: ${dashboardData.topCategories.map(c => c.category).join(', ')}

Focus on trends, notable patterns, and business implications. Be concise and professional.`;

const response = await fetch('/api/models/go_everyday/_completion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt })
});

const text = await readSSEStream(response);
summaryEl.textContent = text;
} catch (err) {
console.error('Summary generation failed:', err);
summaryEl.textContent = 'Failed to generate summary.';
}
}

// ============================================================================
// STRUCTURED OUTPUT - Key Insights
// ============================================================================

async function extractInsights() {
const insightsEl = document.getElementById('insights');
insightsEl.innerHTML = '<p style="color: var(--text-muted);">Extracting insights...</p>';

try {
const response = await fetch('/api/models/go_everyday/_object', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [{
role: 'user',
parts: [{
type: 'text',
text: `Analyze this sales data and extract 3 key insights:

${JSON.stringify(dashboardData, null, 2)}

Return JSON with this exact structure:
{
"insights": [
{ "title": "Insight Title", "description": "Brief explanation" }
]
}`
}]
}],
schema: {
type: 'object',
properties: {
insights: {
type: 'array',
items: {
type: 'object',
properties: {
title: { type: 'string' },
description: { type: 'string' }
},
required: ['title', 'description']
}
}
},
required: ['insights']
}
})
});

const rawResult = await response.json();

// DEFENSIVE PARSING - _object uses Haiku which sometimes drifts from schema
const result = normalizeInsightsResponse(rawResult);

// Render insights
insightsEl.innerHTML = result.insights.map(insight => `
<div class="insight-card">
<h3>${escapeHTML(insight.title)}</h3>
<p>${escapeHTML(insight.description)}</p>
</div>
`).join('');
} catch (err) {
console.error('Insight extraction failed:', err);
insightsEl.innerHTML = '<p style="color: #ef4444;">Failed to extract insights.</p>';
}
}

function normalizeInsightsResponse(raw) {
// Haiku sometimes returns insights as top-level keys instead of array
if (!raw.insights || !Array.isArray(raw.insights)) {
// Try to collect insight_1, insight_2, etc.
const insights = [];
for (let i = 1; i <= 10; i++) {
const key = `insight_${i}`;
if (raw[key]) {
const item = raw[key];
insights.push({
title: item.title || `Insight ${i}`,
description: item.description || String(item)
});
}
}

if (insights.length > 0) {
return { insights };
}

// Fallback
return {
insights: [{
title: 'Unable to parse insights',
description: 'The AI response did not match the expected format.'
}]
};
}

// Ensure each insight has title and description
const normalized = raw.insights.map((item, i) => {
if (typeof item === 'string') {
return { title: `Insight ${i + 1}`, description: item };
}
return {
title: item.title || `Insight ${i + 1}`,
description: item.description || ''
};
});

return { insights: normalized };
}

// ============================================================================
// STREAMING CHAT - Interactive Q&A
// ============================================================================

async function sendChatMessage() {
const promptEl = document.getElementById('chatPrompt');
const prompt = promptEl.value.trim();
if (!prompt) return;

const messagesEl = document.getElementById('chatMessages');

// Add user message
appendChatMessage('user', prompt);
promptEl.value = '';

// Add assistant placeholder
const assistantMsg = appendChatMessage('assistant', '');

try {
const response = await fetch('/api/models/go_everyday/_chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [{
role: 'user',
parts: [{
type: 'text',
text: `Context: ${JSON.stringify(dashboardData, null, 2)}\n\nUser question: ${prompt}`
}]
}],
system: 'You are a data analyst. Answer questions about the sales data concisely.'
})
});

await streamChatResponse(response, assistantMsg);
} catch (err) {
console.error('Chat failed:', err);
assistantMsg.textContent = 'Error: ' + err.message;
}
}

function appendChatMessage(role, text) {
const messagesEl = document.getElementById('chatMessages');
const msgEl = document.createElement('div');
msgEl.className = `chat-message ${role}`;
msgEl.textContent = text;
messagesEl.appendChild(msgEl);
messagesEl.scrollTop = messagesEl.scrollHeight;
return msgEl;
}

async function streamChatResponse(response, targetEl) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = '';

while (true) {
const { done, value } = await reader.read();
if (done) break;

const chunk = decoder.decode(value, { stream: true });

// Parse SSE chunks (format: 0:"text piece"\n)
for (const line of chunk.split('\n')) {
if (line.startsWith('0:')) {
try {
const text = JSON.parse(line.slice(2));
if (typeof text === 'string') {
fullText += text;
targetEl.textContent = fullText;
}
} catch (e) {
// Ignore parse errors (partial chunks)
}
}
}
}

return fullText;
}

// ============================================================================
// UTILITY - SSE Stream Reader
// ============================================================================

async function readSSEStream(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = '';

while (true) {
const { done, value } = await reader.read();
if (done) break;

const chunk = decoder.decode(value, { stream: true });

for (const line of chunk.split('\n')) {
if (line.startsWith('0:')) {
try {
const text = JSON.parse(line.slice(2));
if (typeof text === 'string') {
fullText += text;
}
} catch (e) {
// Ignore
}
}
}
}

return fullText;
}

// ============================================================================
// UTILITIES
// ============================================================================

function formatCurrency(val) {
if (isNaN(val)) return '--';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0
}).format(val);
}

function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}

init();

Defensive Parsing for _object

The _object endpoint uses a Haiku-class model which sometimes deviates from the schema. Common issues:

Array Fields as Strings

// Expected
{ insights: [{ title: "...", description: "..." }] }

// Sometimes returned
{ insights: "First insight. Second insight." }

Scattered Array Items

// Expected
{ insights: [{ title: "A" }, { title: "B" }] }

// Sometimes returned
{ insight_1: { title: "A" }, insight_2: { title: "B" } }

Defense Strategy

  1. Check type - Is it an array? A string? An object?
  2. Collect scattered keys - Look for item_1, item_2, etc.
  3. Coerce when possible - Wrap strings in arrays
  4. Fallback gracefully - Return sensible defaults for missing fields

Example normalizer:

function normalizeInsightsResponse(raw) {
// Handle array field returned as string
if (typeof raw.insights === 'string') {
return {
insights: [{
title: 'Summary',
description: raw.insights
}]
};
}

// Handle scattered fields
if (!Array.isArray(raw.insights)) {
const insights = [];
for (let i = 1; i <= 10; i++) {
const key = `insight_${i}`;
if (raw[key]) {
insights.push({
title: raw[key].title || `Insight ${i}`,
description: raw[key].description || String(raw[key])
});
}
}
if (insights.length > 0) return { insights };
}

// Ensure each item has required fields
const normalized = (raw.insights || []).map((item, i) => ({
title: item.title || `Insight ${i + 1}`,
description: item.description || ''
}));

return { insights: normalized };
}

Reinforcing the Schema

Include an explicit JSON example in the prompt itself:

{
messages: [{
role: 'user',
parts: [{
type: 'text',
text: `Analyze this data...\n\nReturn JSON with this exact structure:
{
"insights": [
{ "title": "Insight Title", "description": "Brief explanation" }
]
}`
}]
}],
schema: { /* formal schema */ }
}

This gives the model two signals (prompt + schema) and significantly reduces drift.

SSE Stream Format

Both _chat and _completion return Server-Sent Events (SSE) streams:

0:"Hello"
0:" world"
0:"!"

Each line is:

  • Prefix 0: (text chunk type)
  • JSON-encoded string
  • Newline

Accumulate chunks to build the full response:

const chunk = decoder.decode(value, { stream: true });
for (const line of chunk.split('\n')) {
if (line.startsWith('0:')) {
const text = JSON.parse(line.slice(2));
fullText += text;
}
}

Test Locally

npm run dev

Verify:

  • Executive summary generates on load
  • Insights extract correctly (check for schema drift)
  • Chat streams responses in real-time
  • Error handling works if API fails

Deploy

npm run deploy

Performance Considerations

  • Use _completion for simple text - Faster than _chat
  • Use _object for structured extraction - But always normalize the response
  • Debounce user input - Don't send a chat request on every keystroke
  • Cache results - Don't regenerate summaries on every page load

Next Steps

You've completed the Magic Apps guide series! You now know how to:

  • Scaffold and deploy apps
  • Query datasets with Elasticsearch
  • Combine multiple data sources
  • Call external APIs via integrations
  • Register AI tools for bidirectional communication
  • Use AI completion endpoints for inline insights

Explore the API Reference for detailed endpoint documentation.