SSE Streaming

How to consume Server-Sent Events from the AI tutor chat endpoint.

4 min read

The POST /chat endpoint streams the AI tutor's response in real time using Server-Sent Events (SSE). This allows your application to display text as it's generated, providing a conversational experience.

Event Types#

The stream emits three event types:

chunk β€” Text delta#

Fired multiple times as the tutor generates its response. Each chunk contains a fragment of text.

event: chunk
data: {"type":"chunk","content":"μ•ˆλ…•"}

event: chunk
data: {"type":"chunk","content":"ν•˜μ„Έμš”! "}

event: chunk
data: {"type":"chunk","content":"How can I help you today?"}

Concatenate all content values to build the full response.

done β€” Stream complete#

Fired once when the tutor finishes generating. Contains metadata about the response.

event: done
data: {"type":"done","conversation_id":"clxyz123...","corrections":[],"usage":{"input_tokens":42,"output_tokens":156},"model":{"provider":"anthropic","id":"claude-sonnet-4-20250514"},"title":"Getting Started with Korean"}
FieldDescription
conversation_idThe conversation ID (use for follow-up messages)
correctionsGrammar/spelling corrections the tutor detected
usageToken counts (input_tokens, output_tokens)
modelProvider and model ID used for the response
titleConversation title (only on first message)
usage_warningQuota warning if nearing limit (optional)

error β€” Stream error#

Fired if an error occurs during generation. The stream closes after this event.

event: error
data: {"type":"error","message":"Stream failed","request_id":"req_abc","retryable":true,"reason":"rate_limit"}
FieldDescription
messageHuman-readable error description
request_idRequest ID for debugging
retryableWhether the request can be safely retried
reasonError category: rate_limit, timeout, internal

Consuming SSE in TypeScript#

Using the Fetch API#

TypeScript
async function chat(message: string, conversationId?: string) {
  const response = await fetch("https://api.chamelingo.com/api/v1/chat", {
    method: "POST",
    headers: {
      "Authorization": "Bearer ck_live_YOUR_KEY_HERE",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      message,
      ...(conversationId && { conversation_id: conversationId }),
    }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error.message);
  }

  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let fullText = "";
  let buffer = "";

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

    buffer += decoder.decode(value, { stream: true });

    // Process complete SSE messages
    const lines = buffer.split("\n");
    buffer = lines.pop() ?? ""; // Keep incomplete line in buffer

    let eventType = "";
    for (const line of lines) {
      if (line.startsWith("event: ")) {
        eventType = line.slice(7);
      } else if (line.startsWith("data: ")) {
        const data = JSON.parse(line.slice(6));

        switch (eventType) {
          case "chunk":
            fullText += data.content;
            process.stdout.write(data.content); // Stream to terminal
            break;
          case "done":
            console.log("\n\nConversation:", data.conversation_id);
            console.log("Tokens:", data.usage);
            return { text: fullText, ...data };
          case "error":
            throw new Error(`Stream error: ${data.message}`);
        }
      }
    }
  }
}

// Start a new conversation
const result = await chat("How do I say 'hello' in Korean?");

// Continue the conversation
await chat("And how about 'goodbye'?", result.conversation_id);

Using EventSource (browser)#

TypeScript
// Note: EventSource only supports GET, so use fetch for POST + manual parsing
// This example uses the eventsource-parser library for cleaner parsing

import { createParser } from "eventsource-parser";

async function streamChat(message: string, onChunk: (text: string) => void) {
  const response = await fetch("https://api.chamelingo.com/api/v1/chat", {
    method: "POST",
    headers: {
      "Authorization": "Bearer ck_live_YOUR_KEY_HERE",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ message }),
  });

  const parser = createParser((event) => {
    if (event.type === "event") {
      const data = JSON.parse(event.data);
      if (event.event === "chunk") {
        onChunk(data.content);
      }
    }
  });

  const reader = response.body!.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    parser.feed(decoder.decode(value, { stream: true }));
  }
}

// Usage in a React component
streamChat("Teach me Korean numbers", (chunk) => {
  setResponse((prev) => prev + chunk);
});

Consuming SSE with cURL#

bash
curl -N -X POST https://api.chamelingo.com/api/v1/chat \
  -H "Authorization: Bearer ck_live_YOUR_KEY_HERE" \
  -H "Content-Type: application/json" \
  -d '{"message": "How do I count in Korean?"}'

The -N flag disables buffering so you see events in real time.

Usage Warnings#

When you're approaching your subscription quota, the done event includes a usage_warning field:

JSON
{
  "type": "done",
  "conversation_id": "clxyz...",
  "usage_warning": {
    "feature": "ai_text_messages",
    "used": 490,
    "limit": 500,
    "remaining": 10,
    "message": "You have 10 messages remaining today."
  }
}

Check this field proactively to notify users before they hit quota limits. Use GET /usage for a full quota breakdown.

Connection Handling#

  • Timeouts: The stream has a 60-second inactivity timeout. If no events arrive within 60 seconds, the connection closes.
  • Client disconnects: If you close the connection, the server detects it and stops generation.
  • Retries: If you receive an error event with retryable: true, wait a few seconds and retry the request. Don't reconnect to the same stream β€” start a new POST /chat request.
Warning

Don't retry on retryable: false errors β€” these indicate a problem with your request (invalid message, missing scope, etc.) that won't be resolved by retrying.