LLM
Charivo's LLM layer is built from two pieces:
@charivo/llmfor conversation state- an
LLMClientimplementation for transport
For production browser apps, pair @charivo/llm with @charivo/llm/remote
and a server route backed by a provider package.
Recommended Stack
@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
messagesand 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 documentedskip_thought_signature_validatorplaceholder 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.