Custom Conversation Processor

Replace the managed LLM with browser-owned conversation logic.

When to use this pattern

Use a custom conversation processor when you want Liforma to handle microphone capture, turn orchestration, avatar speech, and history — but your app owns the "brain". Examples: rule-based receptionists, keyword routing, client-side rubrics, or streaming text you generate in the browser without exposing API keys.

Registered server processors (opaque processorId with encrypted credentials) are a separate upcoming capability. This guide covers the browser function only.

Session setup

Pass conversationProcessor on Experience.startSession(). The SDK mints a session with responseMode: 'custom' and never serializes your function to the API. Typical pairing: mode: 'conversation' and speechInputMode: 'auto'.

const experience = await Experience.startSession({
  experienceId: 'exp_01DEMO1SPANISHCAFE',
  mode: 'conversation',
  speechInputMode: 'auto',
  conversationProcessor: async ({ text, conversation, signal }) => {
    if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
    if (text.toLowerCase().includes('checkout')) {
      return 'Checkout is at 11am. Need a late checkout?';
    }
    if (text.toLowerCase().includes('breakfast')) {
      return 'Breakfast is served until 10 in the lounge.';
    }
    return 'I can help with checkout, breakfast, or directions.';
  }
});

experience.on('conversationProcessorError', ({ utteranceId, message }) => {
  console.error('Processor failed', utteranceId, message);
});

await experience.attach({ container: '#avatar' });

Processor contract

Your function receives:

  • text — finalized user utterance
  • utteranceId — stable id for this capture
  • conversation — immutable snapshot of ConversationMessage[]
  • signalAbortSignal cancelled on interrupt or destroy

Return one of:

  • A string — spoken via speak()
  • { text, characterId?, behavior? } — optional character override and enqueue vs interrupt
  • AsyncIterable<string> — streamed chunks; the player speaks sentence segments as they arrive
conversationProcessor: async function* ({ text, signal }) {
  const chunks = buildStoryChunks(text);
  for (const chunk of chunks) {
    if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
    yield chunk;
  }
}

Errors and cancellation

Processor failures emit conversationProcessorError. Liforma does not fall back to the managed LLM. Handle retries or speak a fallback line in your handler.

experience.on('conversationProcessorError', ({ utteranceId, message }) => {
  showErrorBanner(message);
});

// speak({ behavior: 'interrupt' }) aborts an in-flight processor via signal.

Transcript observation

Use onUserTranscript or userTranscript events for live captions. Partial updates (isFinal: false) are ephemeral UI data; only finals commit to history and trigger the processor.

experience.on('userTranscript', (update) => {
  if (!update.isFinal) {
    liveCaptionEl.textContent = update.text;
    return;
  }
  liveCaptionEl.textContent = update.text;
  commitUtterance(update.utteranceId, update.text);
});

experience.on('userSpeechStarted', () => micIndicator.classList.add('active'));
experience.on('userSpeechEnded', () => micIndicator.classList.remove('active'));

Related guides