SDK Reference
Capturing Turns

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 LLM
  • meta?: 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 tool
  • meta?: object — Optional metadata (type, target, version)

Returns: Promise<T> — Result from fn()

Auto-captured:

  • tool.name: Tool identifier
  • tool.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-is
  • CaptureTranscript.Masked: Content passed through maskFn before sending
  • CaptureTranscript.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:

  1. Turn event is serialized to NDJSON
  2. Added to buffer (auto-flushed every 5s or when batch size limit reached)
  3. Sent to ingest server via POST /v1/ingest

Important:

  • Always call finish() to ensure events are sent
  • Use try/finally blocks 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