Skip to main content

LLM

Charivo's LLM layer is built from two pieces:

  • @charivo/llm for conversation state
  • an LLMClient implementation for transport

For production browser apps, pair @charivo/llm with @charivo/llm/remote and a server route backed by a provider package.

@charivo/llm
@charivo/llm/remote
your /api/chat route
@charivo/server/openai

This keeps the browser client simple and vendor credentials on the server.

Basic Setup

import { createCharivo } from "@charivo/core";
import { createLLMManager } from "@charivo/llm";
import { createRemoteLLMClient } from "@charivo/llm/remote";

const charivo = createCharivo({
llm: createLLMManager(createRemoteLLMClient({ apiEndpoint: "/api/chat" })),
character: {
id: "hiyori",
name: "Hiyori",
personality: "Cheerful and helpful assistant",
},
});

createCharivo applies the character after attaching, so it reaches the LLM, rendering, and realtime managers together. When you attach by hand instead, call charivo.setCharacter(...) after the attach* calls to keep that character state aligned.

Client Choices

Remote

  • @charivo/llm/remote
  • best default for production browser apps
  • expects your route to receive messages and return { success, message }

Direct OpenAI

  • @charivo/llm/openai
  • useful for local development and testing
  • exposes credentials to the browser

Direct Gemini

  • @charivo/llm/gemini
  • useful for local development and testing
  • exposes credentials to the browser

Direct OpenClaw

  • @charivo/llm/openclaw
  • useful when your app targets an OpenClaw deployment directly
  • best treated as a development or trusted-environment option unless browser access is intentional

Stub

  • @charivo/llm/stub
  • useful for UI work, deterministic demos, and tests

Provider Choices

Remote clients pair with provider packages on the server:

  • @charivo/server/openai
  • @charivo/server/openclaw
  • @charivo/server/gemini — Gemini 3 rejects a resent tool-call turn that lacks a thought signature, so this provider sends Google's documented skip_thought_signature_validator placeholder on the first tool call of each turn, and reasoning continuity across tool rounds is lost

Minimal OpenAI route shape:

const provider = createOpenAILLMProvider({
apiKey: process.env.OPENAI_API_KEY!,
model: "gpt-4.1-nano",
});

const text = await provider.generateResponse(messages);

What @charivo/llm Owns

  • message history
  • character-aware prompt building
  • response generation through an injected client

The client is replaceable. The manager remains the stable place for conversation state.

Avatar Tool Calling

LLMManager can drive @charivo/avatar's tools the same way RealtimeManager does, on top of the recommended remote stack. For what belongs in the catalog below — and how to establish what an expression id actually means — see Avatar Control. The tool loop turns on only when both a tool is registered and the client implements callWithTools@charivo/llm/remote does, provided your route forwards tools to a provider's generateResponseWithTools.

import { createLLMManager } from "@charivo/llm";
import { createRemoteLLMClient } from "@charivo/llm/remote";
import {
buildAvatarControlInstructions,
createAvatarControlTools,
createAvatarResultProjector,
} from "@charivo/avatar";

const catalog = {
expressions: ["Smile", "Sad"],
motions: { Idle: 2, TapBody: 2 },
expressionDescriptions: { Smile: "happy or amused", Sad: "downcast or disappointed" },
motionDescriptions: { Idle: ["resting", "shifting weight"], TapBody: ["waves hello", "folds her arms"] },
};

const manager = createLLMManager(
createRemoteLLMClient({ apiEndpoint: "/api/chat" }),
{
tools: createAvatarControlTools(catalog),
resultProjectors: [createAvatarResultProjector()],
toolInstructions: buildAvatarControlInstructions(catalog),
},
);

charivo.attachLLM(manager);

attachLLM(...) wires the event emitter resultProjectors need to turn successful tool calls into avatar:expression / avatar:motion / avatar:gaze events; a RenderManager on the same Charivo instance already listens for them. The same emitter publishes tool:call / tool:result / tool:error around every tool execution — the events RealtimeManager emits too, so tool activity stays observable from one place.

On the server side, your route needs to accept an optional tools array and call the tool-calling variant of your provider whenever the request needs it — that includes both a tools-carrying request (even tools: [], the terminal round) and a plain request whose messages already contain a tool-call or tool-result turn:

const { messages, tools } = parsedBody;
const needsTools = requiresToolCallingPath(parsedBody);

const result = needsTools
? await provider.generateResponseWithTools(messages, tools ?? [])
: { content: await provider.generateResponse(messages) };

return NextResponse.json({
success: true,
message: result.content,
toolCalls: "toolCalls" in result ? result.toolCalls : undefined,
});

See examples/web/src/app/api/chat-request.ts for the full request-parsing and validation this demo uses (parseChatRequest and requiresToolCallingPath).

The tool loop runs at most 3 rounds before a final tools: [] call forces a text-only reply, and only the final assistant text is added to LLMManager's history — see Tool Calling in the package README for the full round-cap and remote protocol details.

History Retention

LLMManager keeps the latest 40 turns by default. A turn is one user message plus one character response, so getHistory() and LLM client calls are bounded to the latest 80 stored messages. This keeps long-running chat sessions from growing memory and context cost without additional app code.

Override the limit with createLLMManager(client, { maxHistoryTurns }), or use maxHistoryTurns: null for unbounded history.

Eviction trims to the exact bound and takes any reply it strands at the head with it, so overlapping generateResponse(...) calls at a tight limit cannot leave a transcript of replies that answer nothing.

Realtime sessions maintain conversation state on the provider side and are not affected by maxHistoryTurns.

Under Charivo Orchestration

The turn's history writes belong to Charivo, not to the manager: userSay(text) places the user message through addToHistory(...), calls generateResponse(..., { callerOwnsHistory: true }) so the manager writes nothing for that call, and commits the reply once the turn reaches presentation. That is what keeps a superseded turn's user message in history while its unspoken reply never enters it — see the latest-wins turn contract in the core README. maxHistoryTurns applies to those messages like any other.

Charivo also passes a per-turn signal alongside isCancelled, so a superseded turn's in-flight request is aborted when the client honors it. @charivo/llm/remote does; the direct OpenAI and Gemini dev clients ignore it and run to completion. A custom LLMManager should forward signal to its client — the built-in one already does.

Alternatives

  • Use OpenClaw when your backend or testing flow targets OpenClaw instead of OpenAI.
  • Use Gemini when you already hold a Gemini API key for Gemini Live realtime voice and want one vendor for both text chat and voice.
  • Use the stub client when you want UI behavior without network or model variability.
  • Use direct browser clients only when development speed matters more than credential isolation.

References