Aug 12, 2026

How to integrate Currai with a streaming chatbot

A beginner-friendly guide to connecting a Next.js chatbot to Currai with the Vercel AI SDK, native HTTP capture, conversation sessions, model traces, and MCP tool events.

FEATURES14 min readThe Currai team / Engineering

A chatbot can return a polished answer and still fail the user.

It may misunderstand the goal, search for the wrong information, call a tool with bad arguments, omit part of a multi-step request, or return an answer that sounds confident but does not help. A server log might say the request returned 200 OK. A model dashboard might show token usage. Neither tells you whether the conversation worked.

That gap becomes harder to manage after launch. Users phrase the same intent in hundreds of ways. Model calls stream over time. Tools execute between tokens. Errors can happen before a response begins or after part of it reaches the browser. One visible answer may depend on several hidden operations.

This guide connects a streaming Next.js chatbot to Currai so one user conversation becomes one observable session. It assumes you are new to Currai and explains each boundary before adding code.

By the end, a real chatbot conversation will appear in Currai with:

  • a stable session shared by every turn in the conversation;
  • a readable user and assistant exchange in User Stories;
  • a top-level agent event for each chat turn;
  • a nested model event with provider, model, latency, finish reason, and token metadata;
  • optional MCP tool events nested beneath the model execution;
  • failure evidence that does not break the user's chat request.

What is a streaming chatbot?

A traditional HTTP response arrives when the server has finished all its work. A streaming chatbot sends the answer in small pieces as the model generates it. The user can begin reading before the full response is complete.

In this guide, the browser uses the Vercel AI SDK's useChat hook and sends messages to a Next.js route. The server uses streamText to call an AI model and returns a UI message stream.

The same pattern applies to many chat products:

  1. The browser stores and displays conversation messages.
  2. A server route authenticates the model request.
  3. The model streams an answer.
  4. Optional tools run while the model works.
  5. The server returns the stream without exposing private API keys.

What is Currai?

Currai is a user-intelligence and agent-quality platform. Your application sends it sessions and structured events. Currai uses that evidence to connect what the user experienced with what the AI system did internally.

For a chatbot, that means product and engineering teams can investigate:

  • what users were trying to accomplish;
  • which intents appear repeatedly;
  • where users rephrase, abandon, or correct the assistant;
  • which model and tool operations produced an answer;
  • which failures and policy violations are increasing;
  • whether a prompt, model, or product change improved real outcomes.

Currai does not replace your model provider, the Vercel AI SDK, or application logs. It adds a conversation-centered record across them. The integration in this guide uses native HTTP, so you do not need to add a Currai SDK dependency.

The architecture

The browser owns chat presentation and a non-secret conversation UUID. The server owns the OpenAI key, Currai credentials, model execution, and event capture.

Diagram showing a chat interface sending messages and a stable session UUID to a Next.js server route, which streams the model response while sending a Currai session, top-level agent event, nested model event, and optional MCP tool event

The response stream and the observability path share the same server boundary, but Currai failure never becomes a chatbot failure.

One turn should produce this trace:

session: complete browser conversation

agent: one user-to-assistant turn
 model: one streamed provider execution
     mcp_tool: optional search or fetch operation

The agent event is the readable conversation boundary. The model and tool events explain how the answer was produced.

What you need

Prepare:

  • a Next.js application with a server-side chat route;
  • the Vercel AI SDK packages used by your application;
  • a server-side model provider key;
  • a Currai workspace and ingestion key;
  • Node.js and your project's package manager.

This guide uses TypeScript, @ai-sdk/react, @ai-sdk/openai, and AI SDK 5-style APIs. Adapt the provider import if your chatbot uses another supported model. The Currai event shape stays the same.

Step 1: Prove the chatbot works first

Run your application before adding capture:

pnpm dev

Open the local URL and send a simple message:

Explain retrieval-augmented generation in three sentences.

Confirm the answer streams into the page. If the baseline fails, inspect the model key, server route, request payload, and browser console first. Currai can make a working path observable, but it should not be used to hide an existing chat bug.

Step 2: Add server-only Currai credentials

Create an ingestion key during Currai onboarding or from Workspace Settings → API Keys. Add the values to your server environment:

CURRAI_PUBLIC_KEY=<your-currai-public-key>
CURRAI_SECRET_KEY=<your-currai-secret-key>
CURRAI_BASE_URL=https://www.currai.app

Keep your provider key in the same trusted environment:

OPENAI_API_KEY=<your-openai-api-key>
OPENAI_MODEL=<your-model-id>

Never add NEXT_PUBLIC_ to a Currai secret or model provider key. Next.js exposes variables with that prefix to browser code.

Restart the application after changing the environment. A running server does not automatically receive new values.

You can also install the Currai Skill and ask a coding agent to wire the real chat route:

npx skills add https://github.com/curraiapp/skills --skill currai

Step 3: Give the browser one stable conversation ID

Currai needs to know which turns belong to the same conversation. Generate one UUID when the conversation begins and send it with every chat request.

One simple AI SDK pattern is to use the first user message ID as the session ID. Configure message IDs as UUIDs, then add that first ID to the request body:

"use client";

import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";

const transport = new DefaultChatTransport({
  api: "/api/chat",
  prepareSendMessagesRequest({ messages }) {
    const sessionId = messages.find((message) => message.role === "user")?.id;

    return {
      body: { messages, sessionId },
    };
  },
});

export function Chat() {
  const chat = useChat({
    transport,
    generateId: () => crypto.randomUUID(),
  });

  // Render messages and the composer with `chat`.
}

The first user message remains in the history, so later turns reuse its ID. Clearing the browser conversation removes that history. The next first message receives a new UUID and starts a new Currai session.

For an authenticated product, prefer your real privacy-reviewed user ID for userId, while keeping a separate conversation UUID for sessionId. Do not derive identity from message text, an authorization token, or an IP address.

Step 4: Validate the chat request on the server

The server route should reject malformed requests before calling the model or Currai:

import type { UIMessage } from "ai";

type ChatBody = {
  messages?: UIMessage[];
  sessionId?: string;
};

const UUID_PATTERN =
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

const body = (await request.json()) as ChatBody;

if (!Array.isArray(body.messages)) {
  return Response.json({ error: "messages must be an array" }, { status: 400 });
}

if (!body.sessionId || !UUID_PATTERN.test(body.sessionId)) {
  return Response.json({ error: "sessionId must be a UUID" }, { status: 400 });
}

Production routes should also limit request size, message count, and message length according to the product's expected use. Validation protects both the model request and the observability path.

Step 5: Create a dependency-free Currai helper

Create a server-only module such as lib/currai.ts. It needs two operations:

POST /api/v1/capture-session
POST /api/v1/capture-event

Authenticate with HTTP Basic auth built from the Currai public and secret keys:

async function send(path: string, body: unknown): Promise<boolean> {
  const baseUrl = process.env.CURRAI_BASE_URL;
  const publicKey = process.env.CURRAI_PUBLIC_KEY;
  const secretKey = process.env.CURRAI_SECRET_KEY;

  if (!baseUrl || !publicKey || !secretKey) return false;

  try {
    const response = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization:
          "Basic " +
          Buffer.from(`${publicKey}:${secretKey}`).toString("base64"),
      },
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(2_000),
    });

    return response.ok;
  } catch {
    return false;
  }
}

Before serialization, recursively redact object keys matching authorization, cookies, passwords, secrets, API keys, and access or refresh tokens. Capture only the conversation data and metadata you need. Currai observability should follow the same privacy and retention rules as the product itself.

The helper returns a boolean instead of throwing. A capture outage must never turn a valid model response into an error for the user.

Step 6: Create the session before the first event

At the beginning of every server turn, create or confirm the Currai session:

const sessionId = body.sessionId;
const environment =
  process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "development";
const release = process.env.VERCEL_GIT_COMMIT_SHA;

const sessionInput = {
  sessionId,
  userId: `chat:${sessionId}`,
  metadata: {
    route: "/api/chat",
    runtime: "nextjs",
    environment,
    ...(release ? { release } : {}),
  },
};

let sessionAccepted = await captureCurraiSession(sessionInput);

Sending the session again on later turns is safe when the ingestion contract is idempotent for the same session UUID. The important ordering rule is that the session must be accepted before any event refers to it.

If the first attempt fails, keep a small ensureCurraiSession() function that retries before later finish, tool, or error events. Do not retry forever or delay the user's stream for a long observability timeout.

Step 7: Allocate event IDs before starting the model

Create stable IDs and start timing immediately before the AI operation:

const agentEventId = crypto.randomUUID();
const modelEventId = crypto.randomUUID();
const startedAt = Date.now();
const modelId = process.env.OPENAI_MODEL ?? "<your-default-model>";

These IDs define the parent-child trace:

  • the agent event has no parent and represents the conversational turn;
  • the model event uses parentId: agentEventId;
  • each model-invoked tool uses parentId: modelEventId.

Allocate IDs before the work begins so failures and success describe the same attempt. Tool IDs should be allocated as soon as their input becomes available, not after execution finishes.

Step 8: Capture the completed streamed answer

Call streamText normally. Add capture in lifecycle callbacks without consuming or replacing the user-facing stream:

const result = streamText({
  model: openai(modelId),
  messages: convertToModelMessages(body.messages),

  async onFinish({ text, finishReason, totalUsage }) {
    const latency = Date.now() - startedAt;
    await ensureCurraiSession();

    await captureCurraiEvent({
      eventId: modelEventId,
      sessionId,
      sessionAccepted,
      parentId: agentEventId,
      kind: "model",
      name: "openai.streamText",
      args: { messages: body.messages },
      result: { text },
      latency,
      timestamp: startedAt,
      metadata: {
        provider: "openai",
        model: modelId,
        finishReason,
        tokens: totalUsage,
        route: "/api/chat",
        environment,
      },
    });

    await captureCurraiEvent({
      eventId: agentEventId,
      sessionId,
      sessionAccepted,
      kind: "agent",
      name: "chat.turn",
      args: { messages: body.messages },
      result: { text },
      latency,
      timestamp: startedAt,
      metadata: { route: "/api/chat", environment },
    });
  },
});

return result.toUIMessageStreamResponse();

onFinish receives the accumulated final text. Capturing there preserves the normal stream while storing the complete assistant answer once.

Include provider, model, finish reason, usage, route, environment, and release metadata only when the application knows them. Do not guess costs, tokens, or model names.

Step 9: Follow the User Story conversation contract

Currai renders readable conversation messages from recognized fields on top-level agent events.

For user input, use one of these supported shapes:

  • args.input;
  • args.prompt;
  • args.message;
  • supported text content or parts inside args.messages.

For the assistant response, use:

  • result.output;
  • result.text;
  • result.response;
  • result.answer.

The streamed chat turn above uses args.messages and result.text. That gives Currai both sides of the conversation.

Custom properties such as rawTranscript, chatLog, or completionPayload may appear in the technical trace, but they do not replace recognized conversation fields. When a top-level agent event exists, Currai will not use a nested model event to repair missing agent evidence.

This creates a common false positive during integration: the session and model event appear, but User Stories contains an empty conversation. Fix the top-level agent args and result; do not add a new backend transcript shape.

Step 10: Capture model failures

Streaming can fail before the first token or after a partial answer. Use the same preallocated event IDs and record both the model and agent boundaries as failed:

async function captureFailure(error: unknown) {
  const latency = Date.now() - startedAt;
  const failure = {
    error: error instanceof Error ? error.message : String(error),
  };

  await ensureCurraiSession();

  await Promise.all([
    captureCurraiEvent({
      eventId: modelEventId,
      sessionId,
      sessionAccepted,
      parentId: agentEventId,
      kind: "model",
      name: "openai.streamText",
      args: { messages: body.messages },
      result: failure,
      success: false,
      latency,
      timestamp: startedAt,
      metadata: { provider: "openai", model: modelId },
    }),
    captureCurraiEvent({
      eventId: agentEventId,
      sessionId,
      sessionAccepted,
      kind: "agent",
      name: "chat.turn",
      args: { messages: body.messages },
      result: failure,
      success: false,
      latency,
      timestamp: startedAt,
    }),
  ]);
}

Guard this function with a settled boolean so onError and an outer catch cannot capture the same failure twice. Preserve the chatbot's normal error response after capture.

Step 11: Add MCP tool events when the chatbot uses tools

MCP, or Model Context Protocol, lets the model call tools exposed by an MCP server. A search-enabled chatbot may call a web search tool and then fetch a source before composing its answer.

For each tool call:

  1. Allocate an event UUID and timestamp when tool input becomes available.
  2. Wait for the final tool result, ignoring preliminary results.
  3. Capture the input, output, success state, and latency.
  4. Set kind: "mcp_tool".
  5. Set parentId: modelEventId.
  6. Include the MCP server, transport, and provider tool-call ID as metadata.

The resulting event resembles:

await captureCurraiEvent({
  eventId: toolRun.eventId,
  sessionId,
  sessionAccepted,
  parentId: modelEventId,
  kind: "mcp_tool",
  name: `search.${toolName}`,
  args: toolInput,
  result: toolOutput,
  success: !failed,
  latency: Date.now() - toolRun.startedAt,
  timestamp: toolRun.startedAt,
  metadata: {
    server: "your-mcp-server",
    transport: "streamable-http",
    toolCallId,
    route: "/api/chat",
    environment,
  },
});

If the MCP connection itself fails, capture a failed mcp_tool event with the connection error. The chatbot may continue without tools if that behavior is safe for the request. Always close per-request MCP clients when the stream finishes, errors, or is aborted.

Step 12: Verify one real conversation

Run your normal checks:

pnpm typecheck
pnpm lint
pnpm build

Restart the app, then send at least two messages in the same browser conversation. If tools are configured, make the second request require one:

What changed in the latest release of this product? Use current sources and include the URLs.

In Currai, verify:

  1. One session exists for the conversation. Both turns share the same session UUID.
  2. User Stories renders messages. At least one user or assistant message is visible, not merely an empty story row.
  3. Each turn has a top-level agent event. The agent contains recognized message input and final text output.
  4. The model is nested under the agent. Provider metadata, latency, finish reason, and token usage appear when available.
  5. Tools are nested under the model. Tool input, final output, success, and latency belong to the correct model call.
  6. A real failure appears in Errors. Test safely in development with an invalid model ID or controlled tool failure, then restore the configuration.

A connectivity event is not enough. Completion requires the real chat route, correct nesting, and a rendered User Story conversation.

Common problems and fixes

Every message creates a new session

The client is generating a session UUID during every request. Create it once per conversation, store it in component state or derive it from the first user message ID, and reuse it until the user starts a new conversation.

Different conversations merge into one session

The session ID is global or persisted longer than the visible conversation. Reset it when the user selects New chat or clears the conversation. A user ID may span many conversations; a session ID should not.

Currai receives events, but User Stories is empty

Inspect the top-level agent event. Put user messages in recognized args fields and the final assistant answer in result.text or result.output. Custom log objects and nested model evidence do not replace that contract.

Only part of the assistant answer is captured

Capture in onFinish, which receives the accumulated final text. Do not read the response stream a second time or capture each token as a separate turn.

The chatbot stops when Currai is unavailable

The capture helper is throwing or waiting too long. Use a short timeout, catch network errors, return a boolean, and preserve the model response independently of capture success.

Tool calls appear beside the model instead of beneath it

Set each tool event's parentId to the current modelEventId. Set the model event's parentId to the agentEventId.

The same failure appears twice

Both the streaming callback and outer request handler are recording it. Use one shared settled flag so the first completion or failure wins.

A tool event has no useful latency

Start its timer when tool input becomes available. Creating the timestamp in onStepFinish measures only the capture request, not tool execution.

Privacy and production checklist

Before deployment, confirm:

  • model and Currai secret keys exist only in server code;
  • authorization headers, cookies, passwords, tokens, and secrets are redacted;
  • message retention matches the product's privacy policy and user consent;
  • request sizes and message history are bounded;
  • one stable session UUID is used per visible conversation;
  • session capture occurs before event capture;
  • agent, model, and tool parent IDs preserve the execution tree;
  • capture uses short timeouts and cannot fail the chat response;
  • failures use success: false and contain safe error messages;
  • provider, model, token, cost, route, release, and environment metadata is included only when known.

What you built

You now have a streaming chatbot whose production conversations can be understood at two levels.

At the user level, Currai can reconstruct the conversation, detect intents and violations, group failures, and show where users struggle. At the execution level, each turn links to the model and tool operations that produced it.

The integration does not change the response stream or add a first-party Currai SDK. It creates a stable conversation UUID in the browser, captures the session first on the server, records final model output in lifecycle callbacks, and preserves the agent → model → tool tree with native HTTP.

For agent-assisted instrumentation, read How to run the Currai Skill. To learn how conversations become production evidence, continue with How to trace a multi-turn chatbot, or start with Currai free.

FAQ

Does Currai replace my model provider dashboard?

No. Provider dashboards explain provider usage. Currai connects those model operations to complete user conversations, intents, failures, violations, and quality outcomes.

Do I need a Currai SDK?

No. This integration uses native authenticated HTTP requests for sessions and events.

Should one message equal one session?

No. One visible multi-turn conversation should normally equal one session. Each user-to-assistant exchange becomes a separate agent event inside it.

Can I capture streaming tokens individually?

You can store token-level details as trace metadata if you have a specific need, but conversation rendering should use the accumulated final answer from onFinish.

Are MCP tools required?

No. A model-only chatbot needs only agent and model events. Add mcp_tool events when the application actually uses MCP tools.

What proves the integration is finished?

One real chatbot conversation must produce a Currai session, correctly nested agent and model events, optional tools under the model, and at least one visible message in User Stories. An empty session or standalone demo event is not enough.

03

Keep going with nearby topics from the Currai blog.