Node Kimi K3 streaming chat

A Kimi K3 chat page streams long answers, renders readable Markdown, and carries the complete thread into every follow up. Tokay runs the Express app as one Web Service and keeps your Moonshot API key encrypted.

Last updated

Use this example when you want to see how a reasoning model's conversation state and usage data move through a small hosted chat. Your Moonshot account needs Kimi K3 access before the Service can answer.

Runtime
Node.js
Framework
Express
Service types
Web Service
Resources
None
Required secrets
MOONSHOT_API_KEY

What it does

The opening prompt asks Kimi to plan a trip, but you can replace it with any question.

The answer arrives piece by piece and supports lists, tables, links, and code. Ask another question and Kimi receives the earlier messages, including the reasoning payload its API requires you to return.

Below each response, the page shows elapsed time and token counts. New chat clears the visible conversation and begins a fresh session.

Deploy it

Bring this folder to Tokay any way you like. Paste or upload it in the dashboard, push it with git, or hand it to your AI agent along with our agent instructions. Tokay figures out the rest.

Working in Claude, ChatGPT, VS Code, or another MCP client? Connect the Tokay MCP server and ask your agent to deploy this example. It signs in through your browser, so there is no token to paste.

New to Tokay? The getting started guide walks you through your first deploy.

Add your Kimi API key

Create a key in the Kimi API Platform. On the Service page, open Environment and find MOONSHOT_API_KEY under From your code. Choose Set shared value, keep the value secret, and save your Kimi key.

Once the Kimi key is Good to go, finish the deployment tracker and use Go Live if offered. Open the chat from the live URL at the top of the Service.

Try it

  1. Send the trip question and wait for the streamed answer to finish.
  2. Ask Kimi to revise one detail from that answer without restating the plan.
  3. Press New chat and ask the same follow up. The missing context proves you started a separate thread.

How it works

You keep context in the page because the Kimi API is stateless. The browser sends the thread to the server on every turn, and the server forwards visible answer deltas over SSE.

Kimi's reasoning deltas stay out of the interface, but the server retains their complete payload and returns it unchanged on the next request. The demo selects low reasoning effort and lets Moonshot AI enforce the model's context limit.

Marked and DOMPurify turn model Markdown into safe page content. Your API key and conversation are never written to application storage.

Secrets

Name Visibility Required Purpose
MOONSHOT_API_KEY Secret Yes Authenticates requests to Moonshot AI.

Grow from here

Try the AI research queue when Kimi should work from saved evidence in a recoverable job.

src/server.ts Raw

import express, { type Request, type Response } from "express";
import OpenAI from "openai";

const app = express();
app.use(express.json({ limit: "10mb" }));

const moonshotApiKey = process.env.MOONSHOT_API_KEY?.trim() || "";
const model = "kimi-k3";
const modelName = "Kimi K3";
const modelContextLength = 1_048_576;
const outputTokenLimit = 4_000;
const systemPrompt = "You are Kimi, a concise, helpful AI assistant provided by Moonshot AI. Answer the user's question directly and clearly.";

type UserConversationMessage = {
  role: "user";
  content: string;
};

type AssistantConversationMessage = {
  role: "assistant";
  content: string;
  reasoningContent: string;
};

type ConversationMessage = UserConversationMessage | AssistantConversationMessage;

type KimiUsage = {
  prompt_tokens: number;
  completion_tokens: number;
  total_tokens: number;
  cached_tokens?: number;
  prompt_tokens_details?: { cached_tokens?: number };
};

const sendEvent = (response: Response, event: Record<string, unknown>) => {
  response.write(`data: ${JSON.stringify(event)}\n\n`);
};

const parseConversation = (value: unknown): ConversationMessage[] | null => {
  if (!Array.isArray(value) || value.length === 0 || value.length % 2 === 0) return null;

  const conversation: ConversationMessage[] = [];
  for (let index = 0; index < value.length; index += 1) {
    const item = value[index];
    if (!item || typeof item !== "object") return null;
    const role = (item as { role?: unknown }).role;
    const rawContent = (item as { content?: unknown }).content;
    const expectedRole: ConversationMessage["role"] = index % 2 === 0 ? "user" : "assistant";
    if (role !== expectedRole || typeof rawContent !== "string") return null;

    if (expectedRole === "user") {
      const content = rawContent.trim();
      if (!content || content.length > 600) return null;
      conversation.push({ role: "user", content });
      continue;
    }

    const reasoningContent = (item as { reasoningContent?: unknown }).reasoningContent;
    if (!rawContent.trim() || rawContent.length > 24_000 || typeof reasoningContent !== "string" || reasoningContent.length > 48_000) {
      return null;
    }
    conversation.push({ role: "assistant", content: rawContent, reasoningContent });
  }
  return conversation;
};

const parseSessionId = (value: unknown): string | null => {
  if (typeof value !== "string") return null;
  const sessionId = value.trim();
  return /^[A-Za-z0-9._:-]{1,256}$/.test(sessionId) ? sessionId : null;
};

const streamKimiAnswer = async (
  client: OpenAI,
  conversation: ConversationMessage[],
  sessionId: string,
  response: Response
) => {
  const messages = [
    { role: "system" as const, content: systemPrompt },
    ...conversation.map(message => message.role === "assistant"
      ? { role: "assistant" as const, content: message.content, reasoning_content: message.reasoningContent }
      : message)
  ];
  const request = {
    model,
    messages: messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
    max_completion_tokens: outputTokenLimit,
    reasoning_effort: "low" as const,
    prompt_cache_key: sessionId,
    stream: true as const,
    stream_options: { include_usage: true }
  };
  const stream = await client.chat.completions.create(request);
  let answer = "";
  let reasoningContent = "";
  let finishReason: string | null = null;

  for await (const chunk of stream) {
    const usage = (chunk as unknown as { usage?: KimiUsage }).usage;
    if (usage) {
      sendEvent(response, {
        type: "usage",
        promptTokens: usage.prompt_tokens,
        completionTokens: usage.completion_tokens,
        totalTokens: usage.total_tokens,
        cachedTokens: usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? 0
      });
    }

    const choice = chunk.choices?.[0] as unknown as {
      delta?: { content?: string | null; reasoning_content?: string | null };
      finish_reason?: string | null;
    } | undefined;
    if (!choice) continue;

    if (choice.delta?.reasoning_content) reasoningContent += choice.delta.reasoning_content;
    if (choice.delta?.content) {
      answer += choice.delta.content;
      sendEvent(response, { type: "delta", text: choice.delta.content });
    }
    if (choice.finish_reason) finishReason = choice.finish_reason;
  }

  if (finishReason === "length") {
    throw new Error(`${modelName} reached this demo's output limit. Ask for a shorter answer.`);
  }
  if (!answer) throw new Error(`${modelName} returned no text. Try again.`);

  sendEvent(response, {
    type: "history",
    message: { role: "assistant", content: answer, reasoningContent }
  });
};

app.get("/health", (_request, response) => {
  response.json({ ok: true });
});

app.get("/api/model", (_request, response) => {
  response.json({
    configured: Boolean(moonshotApiKey),
    model,
    name: modelName,
    contextLength: modelContextLength,
    missing: moonshotApiKey ? [] : ["MOONSHOT_API_KEY"]
  });
});

app.post("/api/chat", async (request: Request, response: Response) => {
  const conversation = parseConversation(request.body?.messages);
  const sessionId = parseSessionId(request.body?.sessionId);
  if (!conversation) {
    response.status(400).json({ error: "Send a valid conversation ending with a message from 1 to 600 characters." });
    return;
  }
  if (!sessionId) {
    response.status(400).json({ error: "Start a new chat session and try again." });
    return;
  }
  if (!moonshotApiKey) {
    response.status(503).json({ error: "Add MOONSHOT_API_KEY to start a live Kimi K3 conversation." });
    return;
  }

  const client = new OpenAI({
    apiKey: moonshotApiKey,
    baseURL: "https://api.moonshot.ai/v1"
  });

  response.setHeader("Content-Type", "text/event-stream");
  response.setHeader("Cache-Control", "no-cache, no-transform");
  response.setHeader("Connection", "keep-alive");
  response.flushHeaders();

  try {
    await streamKimiAnswer(client, conversation, sessionId, response);
    sendEvent(response, { type: "done" });
  } catch (error) {
    const message = error instanceof Error ? error.message : "The Kimi request failed.";
    console.error("Kimi request failed", message);
    sendEvent(response, { type: "error", message });
  } finally {
    response.end();
  }
});

app.get("/", (_request, response) => {
  response.type("html").send(`<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Chat with Kimi K3</title>
  <style>
    :root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #f6f6f4; color: #20201e; }
    * { box-sizing: border-box; }
    body { height: 100dvh; margin: 0; padding: 24px; overflow: hidden; }
    main { width: min(820px, 100%); height: calc(100dvh - 48px); margin: 0 auto; background: #fff; border: 1px solid #e3e3df; border-radius: 16px; box-shadow: 0 18px 55px #20201e0a; overflow: hidden; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; }
    header { padding: 26px 34px 22px; display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
    h1 { margin: 0; font-size: clamp(1.7rem, 4vw, 2.45rem); line-height: 1.08; letter-spacing: -0.035em; font-weight: 650; }
    #status { display: flex; align-items: center; gap: 8px; margin-top: 12px; color: #72726d; font-size: 0.8rem; overflow-wrap: anywhere; }
    #status::before { content: ''; width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; background: #2f9e63; }
    #messages { min-height: 0; padding: 28px 34px; border-top: 1px solid #ecece8; overflow-y: auto; overscroll-behavior: contain; display: flex; flex-direction: column; gap: 24px; scroll-behavior: smooth; }
    .message { line-height: 1.62; overflow-wrap: anywhere; }
    .user { width: 100%; padding: 13px 15px; background: #f3f3f0; border-radius: 8px; color: #3f3f3b; }
    .assistant { padding: 0 2px; font-size: 1.01rem; }
    .assistant.error .message-content { color: #a23c32; }
    .message p { margin: 0 0 0.8em; }
    .message p:last-child { margin-bottom: 0; }
    .message ul, .message ol { margin: 0.6em 0; padding-left: 1.5em; }
    .message pre { overflow-x: auto; padding: 12px 14px; border-radius: 8px; background: #eeeeea; white-space: pre; }
    .message code { padding: 0.12em 0.3em; border-radius: 4px; background: #eeeeea; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
    .message pre code { padding: 0; background: transparent; }
    .message blockquote { margin: 0.8em 0; padding-left: 1em; border-left: 3px solid #d1d1cc; color: #666660; }
    .message a { color: #405da7; }
    .message table { display: block; width: max-content; max-width: 100%; overflow-x: auto; border-collapse: collapse; }
    .message th, .message td { padding: 9px 12px; border: 1px solid #d7d7d2; text-align: left; vertical-align: top; overflow-wrap: normal; }
    .message th { background: #f3f3f0; }
    .message-meta { min-height: 18px; margin-top: 7px; color: #92928c; font-size: 0.76rem; line-height: 1.35; }
    form { padding: 18px 34px 24px; border-top: 1px solid #ecece8; background: #fff; }
    textarea { display: block; width: 100%; height: 72px; resize: none; border: 1px solid #d7d7d2; border-radius: 10px; padding: 12px 14px; background: #fff; color: inherit; font: inherit; line-height: 1.45; }
    textarea:focus { outline: 3px solid #dfe7ff; border-color: #6278b8; }
    textarea:disabled { background: #f2f2ef; color: #888883; }
    .actions { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-top: 11px; }
    .hint { color: #969690; font-size: 0.76rem; }
    button { border: 0; border-radius: 8px; padding: 9px 16px; background: #262624; color: #fff; font: inherit; font-weight: 650; cursor: pointer; }
    button:hover { background: #41413d; }
    button:focus-visible { outline: 3px solid #bdc9ec; outline-offset: 2px; }
    button:disabled { opacity: 0.45; cursor: wait; }
    #new-chat { flex: 0 0 auto; padding: 8px 11px; border: 1px solid #deded9; background: #fff; color: #666660; font-size: 0.78rem; font-weight: 600; }
    #new-chat:hover { background: #f5f5f2; color: #252522; }
    @media (max-width: 560px) {
      body { padding: 0; }
      main { height: 100dvh; border: 0; border-radius: 0; }
      header { padding: 22px; }
      #messages { padding: 24px 22px; }
      form { padding: 16px 22px 20px; }
      .hint { display: none; }
    }
  </style>
</head>
<body>
  <main>
    <header>
      <div>
        <h1>Chat with Kimi K3</h1>
        <span id="status">Checking Moonshot AI setup</span>
      </div>
      <button id="new-chat" type="button">New chat</button>
    </header>
    <section id="messages" aria-live="polite"></section>
    <form id="chat-form">
      <textarea id="message" aria-label="Message" maxlength="600" disabled>Which five U.S. cities are the most popular tourist destinations, and what makes each one worth visiting?</textarea>
      <div class="actions">
        <span class="hint">Enter to send · Shift + Enter for a new line</span>
        <button id="send" type="submit" disabled>Send</button>
      </div>
    </form>
  </main>
  <script src="https://cdn.jsdelivr.net/npm/marked@18.0.7/lib/marked.umd.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.12/dist/purify.min.js"></script>
  <script>
    const messages = document.querySelector('#messages');
    const status = document.querySelector('#status');
    const form = document.querySelector('#chat-form');
    const input = document.querySelector('#message');
    const button = document.querySelector('#send');
    const newChatButton = document.querySelector('#new-chat');
    const startingPrompt = input.value;
    let conversation = [];
    let sessionId = crypto.randomUUID();
    let configured = false;
    let sending = false;

    function scrollToLatest() {
      messages.scrollTop = messages.scrollHeight;
    }

    function renderMarkdown(element, markdown) {
      element.innerHTML = DOMPurify.sanitize(marked.parse(markdown));
    }

    function addUserMessage(text) {
      const element = document.createElement('div');
      element.className = 'message user';
      renderMarkdown(element, text);
      messages.append(element);
      scrollToLatest();
    }

    function addAssistantMessage() {
      const element = document.createElement('div');
      element.className = 'message assistant';
      const content = document.createElement('div');
      content.className = 'message-content';
      const meta = document.createElement('div');
      meta.className = 'message-meta';
      element.append(content, meta);
      messages.append(element);
      scrollToLatest();
      return { element, content, meta };
    }

    function secondsSince(startedAt) {
      return ((performance.now() - startedAt) / 1000).toFixed(1) + 's';
    }

    function formatTokens(value) {
      if (value >= 1000000) return (value / 1000000).toFixed(value % 1000000 === 0 ? 0 : 1) + 'M';
      if (value >= 1000) return (value / 1000).toFixed(value % 1000 === 0 ? 0 : 1) + 'K';
      return String(value);
    }

    function completedMeta(startedAt, requestState) {
      const parts = ['Responded in ' + secondsSince(startedAt)];
      if (requestState.usage) {
        parts.push(formatTokens(requestState.usage.promptTokens) + ' input tokens');
        parts.push(formatTokens(requestState.usage.completionTokens) + ' output tokens');
        if (requestState.usage.cachedTokens > 0) parts.push(formatTokens(requestState.usage.cachedTokens) + ' cached');
      }
      return parts.join(' · ');
    }

    async function readEvents(response, requestState) {
      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';
      while (true) {
        const result = await reader.read();
        if (result.done) break;
        buffer += decoder.decode(result.value, { stream: true });
        const blocks = buffer.split('\\n\\n');
        buffer = blocks.pop() || '';
        for (const block of blocks) {
          const line = block.split('\\n').find(value => value.startsWith('data: '));
          if (!line) continue;
          const event = JSON.parse(line.slice(6));
          if (event.type === 'delta') {
            requestState.phase = 'Responding';
            requestState.text += event.text;
            renderMarkdown(requestState.assistant.content, requestState.text);
            scrollToLatest();
          }
          if (event.type === 'usage') requestState.usage = event;
          if (event.type === 'history') requestState.historyMessage = event.message;
          if (event.type === 'done') requestState.done = true;
          if (event.type === 'error') throw new Error(event.message);
        }
      }
    }

    fetch('/api/model').then(response => response.json()).then(data => {
      configured = data.configured;
      if (configured) {
        status.textContent = 'Using ' + data.name + ' through Moonshot AI · ' + formatTokens(data.contextLength) + ' context';
        input.disabled = false;
        button.disabled = false;
        input.focus();
      } else {
        status.textContent = data.missing.join(' and ') + (data.missing.length === 1 ? ' is required' : ' are required');
      }
    }).catch(() => {
      status.textContent = 'Could not check the Moonshot AI setup';
    });

    form.addEventListener('submit', async event => {
      event.preventDefault();
      if (sending || !configured) return;
      const text = input.value.trim();
      if (!text) return;

      input.value = '';
      addUserMessage(text);
      conversation.push({ role: 'user', content: text });
      const startedAt = performance.now();
      const assistant = addAssistantMessage();
      const requestState = { assistant, text: '', phase: 'Thinking', usage: null, historyMessage: null, done: false };
      assistant.meta.textContent = 'Thinking… 0.0s';
      const timer = setInterval(() => {
        assistant.meta.textContent = requestState.phase + '… ' + secondsSince(startedAt);
      }, 100);
      sending = true;
      button.disabled = true;
      newChatButton.disabled = true;
      button.textContent = 'Sending…';

      try {
        const response = await fetch('/api/chat', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ messages: conversation, sessionId })
        });
        if (!response.ok) {
          const body = await response.json();
          throw new Error(body.error || 'The request failed.');
        }
        await readEvents(response, requestState);
        if (!requestState.done || !requestState.text || !requestState.historyMessage) throw new Error('Kimi K3 returned no complete answer. Try again.');
        conversation.push(requestState.historyMessage);
        assistant.meta.textContent = completedMeta(startedAt, requestState);
      } catch (error) {
        conversation.pop();
        if (requestState.text) requestState.text += '\\n\\n';
        requestState.text += error.message;
        renderMarkdown(assistant.content, requestState.text);
        assistant.element.classList.add('error');
        assistant.meta.textContent = 'Stopped after ' + secondsSince(startedAt);
      } finally {
        clearInterval(timer);
        sending = false;
        button.disabled = false;
        newChatButton.disabled = false;
        button.textContent = 'Send';
        input.focus();
        scrollToLatest();
      }
    });

    newChatButton.addEventListener('click', () => {
      if (sending) return;
      conversation = [];
      sessionId = crypto.randomUUID();
      messages.innerHTML = '';
      input.value = startingPrompt;
      input.focus();
    });

    input.addEventListener('keydown', event => {
      if (event.key === 'Enter' && !event.shiftKey) {
        event.preventDefault();
        form.requestSubmit();
      }
    });
  </script>
</body>
</html>`);
});

const port = Number(process.env.PORT || 3000);
app.listen(port, "0.0.0.0", () => {
  console.log(`Kimi K3 chat listening on ${port}`);
});

package.json Raw

{
  "name": "node-kimi-k3-streaming-tool-chat",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=24"
  },
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "start": "node dist/server.js"
  },
  "dependencies": {
    "express": "^5.2.1",
    "openai": "^6.48.0"
  },
  "devDependencies": {
    "@types/express": "^5.0.6",
    "@types/node": "^26.1.1",
    "typescript": "^7.0.2"
  }
}

tsconfig.json Raw

{
  "compilerOptions": {
    "target": "ES2024",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

Built from revision 52c7b7f