Capturing Turns
Learn how to capture turn events with all relevant context. Turns are lightweight events marking key moments in your application.
Note: The package is installed as
lumina-sdk, but we refer to it as the Grounded Intelligence SDK.
Basic Turn Capture
Start Session
First, start a session:
const session = await Lumina.session.start()Note: Multiple session.start() calls will reuse the same trace ID unless the session is explicitly cleared.
Minimum Viable Turn
// Create turn
const turn = session.turn()
// Wrap LLM call
const response = await turn.wrapLLM(
async () => {
return await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: userMessage }]
})
},
{ model: 'gpt-4', prompt_id: 'support-chat' }
)
// Set messages
turn.setMessages([
{ role: 'user', content: userMessage },
{ role: 'assistant', content: response.choices[0].message.content }
])
// Finish
await turn.finish()API Methods
session.turn()
Create a turn event context for capturing data.
Returns: TurnCtx
Example:
const turn = session.turn()turn.wrapLLM(fn, meta?)
Wrap an LLM API call to auto-capture usage, latency, and errors.
Parameters:
fn: () => Promise<T>— Async function that calls LLMmeta?: object— Optional metadata (model, prompt_id, etc.)
Returns: Promise<T> — Result from fn()
Auto-captured:
- Latency (start/end timestamps)
- Model name
- Prompt ID (for versioning)
- Errors (if thrown)
Does NOT capture:
- Token usage (must be added via
annotate()) - Streaming progress
Example:
const response = await turn.wrapLLM(
async () => {
return await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: 'Hello' }]
})
},
{
model: 'gpt-4',
prompt_id: 'greeting_v1',
temperature: 0.7
}
)turn.recordTool(name, fn, meta?)
Wrap a tool call (e.g., RAG, search, API) to capture metadata.
Parameters:
name: string— Tool name (e.g.,"semantic_search")fn: () => Promise<T>— Async function that calls toolmeta?: object— Optional metadata (type, target, version)
Returns: Promise<T> — Result from fn()
Auto-captured:
tool.name: Tool identifiertool.status: "ok" or "error"tool.latency_ms: Execution time- Custom metadata from
opts
Note: Only active if enableToolWrapping: true in config.
Example:
const results = await turn.recordTool(
'semantic_search',
async () => {
return await pinecone.query({
vector: embedding,
topK: 5
})
},
{
type: 'retrieval',
target: 'pinecone',
version: 'v1',
topK: 5
}
)Multiple tools can be recorded per turn:
await turn.recordTool('search', () => searchAPI(query))
await turn.recordTool('rerank', () => rerankResults(results))
await turn.recordTool('format', () => formatContext(rankedResults))turn.addRetrieval(info)
Add retrieval (RAG) metadata for this anchor.
Parameters:
{
source: string // Vector DB name (e.g., "pinecone")
query: string // Search query
results: Array<{
id: string
score: number
content: string
}>
}Example:
turn.addRetrieval({
source: 'pinecone',
query: userMessage,
results: searchResults.map(r => ({
id: r.id,
score: r.score,
content: r.metadata.text
}))
})turn.setMessages(messages)
Set conversation messages for transcript capture.
Parameters:
messages: Array<{
role: 'user' | 'assistant' | 'system'
content: string
}>Privacy:
CaptureTranscript.Full: Sent as-isCaptureTranscript.Masked: Content passed throughmaskFnbefore sendingCaptureTranscript.None: Content not sent (only metadata)
turn.annotate(data)
Add custom annotations to this anchor.
Parameters:
data: Record<string, any>Example:
turn.annotate({
// User feedback
feedback_score: 5,
helpful: true,
flagged: false,
// Business context
ab_test_variant: 'control',
user_tier: 'premium',
conversation_intent: 'support_refund',
// Custom metrics
tags: ['billing', 'urgent'],
priority: 'high',
sentiment_override: 0.8,
// Any JSON-serializable data
custom_scores: { quality: 0.95, relevance: 0.88 }
})turn.finish()
Finalize and send the turn event to the ingest server.
Returns: Promise<void>
Example:
turn.setMessages([...])
await turn.finish()What happens:
- Turn event is serialized to NDJSON
- Added to buffer (auto-flushed every 5s or when batch size limit reached)
- Sent to ingest server via
POST /v1/ingest
Important:
- Always call
finish()to ensure events are sent - Use
try/finallyblocks to guarantee execution - Calling
finish()multiple times is safe (idempotent after first call)
Complete Example
Here's a full example with all features:
import { Lumina } from 'lumina-sdk'
import openai from 'openai'
import posthog from 'posthog-js'
import { pinecone } from './pinecone-instance'
import { langfuse } from './langfuse-instance'
async function handleUserMessage(userMessage: string) {
// Start session
const session = await Lumina.session.start()
// Create turn
const turn = session.turn()
try {
// Perform semantic search
const searchResults = await turn.recordTool(
'semantic_search',
async () => {
const embedding = await openai.embeddings.create({
model: 'text-embedding-ada-002',
input: userMessage
})
return await pinecone.query({
vector: embedding.data[0].embedding,
topK: 5
})
},
{
type: 'retrieval',
target: 'pinecone',
version: 'v1',
topK: 5
}
)
// Add retrieval metadata
turn.addRetrieval({
source: 'pinecone',
query: userMessage,
results: searchResults.matches.map(m => ({
id: m.id,
score: m.score,
content: m.metadata.text
}))
})
// Build context from search results
const context = searchResults.matches
.map(m => m.metadata.text)
.join('\n\n')
// Call LLM with context
const response = await turn.wrapLLM(
async () => {
return await openai.chat.completions.create({
model: 'gpt-4',
messages: [
{
role: 'system',
content: 'You are a helpful customer support assistant. Use the following context to answer:\n\n' + context
},
{ role: 'user', content: userMessage }
]
})
},
{
model: 'gpt-4',
prompt_id: 'support_chat_v3',
temperature: 0.7
}
)
const assistantMessage = response.choices[0].message.content
// Set messages for transcript
turn.setMessages([
{ role: 'user', content: userMessage },
{ role: 'assistant', content: assistantMessage }
])
// Finish turn
await turn.finish()
return assistantMessage
} catch (error) {
// Error is auto-captured by wrapLLM/recordTool
console.error('Failed to handle message:', error)
// Still finish the turn to send error data
await turn.finish()
throw error
}
}Error Handling
The SDK handles errors gracefully:
const turn = session.turn()
try {
const response = await turn.wrapLLM(async () => {
// This throws an error
throw new Error('API rate limit exceeded')
})
} catch (error) {
// Error is auto-captured by wrapLLM
console.error('LLM call failed:', error)
// Optionally add recovery context
turn.annotate({
error_handled: true,
error_recovery: 'showed_cached_response',
fallback_used: true
})
// Always finish to send error data
await turn.finish()
}Auto-captured error data:
- Exception message
- Stack trace (sanitized)
- Error timestamp
tool.status: "error"flag
Multiple Turns per Session
You can create multiple turns within the same session:
// Start session once
const session = await Lumina.session.start({
pointers: { /* ... */ }
})
// Turn 1: User sends first message
const t1 = session.turn()
await t1.wrapLLM(/* ... */)
t1.setMessages([/* first exchange */])
await t1.finish()
// Turn 2: User sends follow-up
const t2 = session.turn()
await t2.wrapLLM(/* ... */)
t2.setMessages([/* second exchange */])
await t2.finish()
// Turn 3: User gives feedback
const t3 = session.turn()
t3.annotate({ feedback: 'thumbs_up' })
await t3.finish()Benefits:
- Pointers sent once at session start
- Each turn shows in the dashboard timeline
- Server correlates all events by session ID
Best Practices
1. Always Call finish()
// ✅ Good: Always finish turn
const turn = session.turn()
try {
await turn.wrapLLM(/* ... */)
turn.setMessages([...])
} finally {
await turn.finish() // Always call, even on error
}
// ❌ Bad: Forgetting to finish
const turn = session.turn()
await turn.wrapLLM(/* ... */)
// Oops, forgot to call finish()!2. Use setMessages() for Clarity
// ✅ Good: Explicit message setting
turn.setMessages([
{ role: 'user', content: userMessage },
{ role: 'assistant', content: assistantMessage }
])
await turn.finish()3. Annotate Liberally
// ✅ Good: Rich annotations for analysis
turn.annotate({
ui_session_id: posthog.get_session_id(),
ui_replay_url: posthog.get_session_replay_url(),
ab_test_variant: 'control',
user_tier: 'premium',
conversation_intent: 'support_refund',
feedback_score: 5,
})4. One Session per Chat Thread
// ✅ Good: Start session when chat session starts
const session = await Lumina.session.start()
// Create multiple turns for same session
const t1 = session.turn(); await t1.finish()
const t2 = session.turn(); await t2.finish()
// ❌ Bad: New session for each message
async function handleMessage() {
const s = await Lumina.session.start() // Don't restart!
}5. Attach UI Context via Annotations
// ✅ Good: Attach UI session info via annotations
turn.annotate({
ui_session_id: uiProvider.getSessionId(),
ui_replay_url: uiProvider.getReplayUrl(),
})
// ❌ Bad: No UI context (lose correlation with session replays)Next Steps
- User Identity — Track users across sessions
- PII Masking — Protect sensitive data
- Batching & Offline — Understand buffering and retries