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. Read the dashboard metrics through a server route that uses a dependency slot for the dataset, and keep the /api/models/go_everyday/_completion endpoint in informer.yaml's access.apis allowlist."

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

The dataset is read through a dependency slot — a typed binding the installer (or your defaultBinding) points at a real dataset — while the AI model endpoints stay in the raw access.apis: allowlist (they're direct API routes, not slot-backed resources). Both blocks coexist in informer.yaml:

dependencies:
orders:
target: dataset
# UUID of your dataset — look it up with `GET /api/datasets-list`.
defaultBinding: 7d5a9b1e-0c83-4bde-9e2a-3a4b5c6d7e8f

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

The slot name (orders) is how your server route refers to the dataset — no dataset IDs appear in your frontend code. The AI model endpoints remain raw API paths under access.apis: because they're called directly, not through a dependency slot. See the informer.yaml reference for the full slot model and the access allowlist for raw API paths.

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; }
}

Server Route

The dataset query runs in a server route that calls the orders slot via context.orders.search(). The frontend never sees a dataset ID:

// server/metrics.js
export async function GET({ context }) {
const result = await context.orders.search({
query: { match_all: {} },
size: 0, // aggregations only — no documents
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' } } }
}
}
});
return result.aggregations;
}

AI Endpoint Implementation

Claude will implement all three AI endpoints with streaming support. The dashboard metrics come from the server route above; the AI calls (_completion, _object, _chat) stay in the frontend because they're direct API endpoints in the access.apis: allowlist:

window.informerReady = false;

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

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() {
// The server route reads the `orders` slot and returns the
// aggregations — no dataset IDs in the frontend.
const response = await fetch('/api/_server/metrics');

if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}

const aggs = await response.json();

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.