Chat
The chat command starts an interactive session that streams responses from the Anuma Portal API. It uses runToolLoop from @anuma/sdk/server to handle the full request cycle: sending messages, streaming tokens, executing client-side tools, and feeding results back to the model.
The Tool Loop
Each user message goes through runToolLoop, which manages streaming and multi-turn tool execution in a single call. The SDK handles SSE parsing, tool call detection, executor dispatch, and continuation requests internally.
const result = await runToolLoop({
messages: messages as any,
model,
token: apiKey,
baseUrl,
headers: sdkHeaders(apiKey),
apiType: "completions",
...(opts.tools && { tools }),
onData: (chunk: string) => {
if (firstToken) {
spinner.stop();
firstToken = false;
}
process.stdout.write(chunk);
},
onError: () => {
if (firstToken) {
spinner.stop();
firstToken = false;
}
},
});The key options:
messagesis the full conversation history, formatted as content arraystokenandheadershandle authentication (the Portal API usesX-API-Key)apiType: "completions"targets the/api/v1/chat/completionsendpointtoolsare only included when--no-toolsis not setonDatastreams tokens to stdout as they arrive
When the model calls a tool that has an executor, the SDK runs it automatically and sends the result back. This loop continues until the model responds with text or hits the max rounds limit (default 3).
Command Definition
The full command handles the REPL loop, model switching, and error display:
export const chat = new Command("chat")
.description("Start an interactive chat session")
.option("--api-url <url>", "API base URL")
.option("--model <name>", "Model to use", "openai/gpt-4o")
.option("--system <prompt>", "System prompt")
.option("--no-tools", "Disable client-side tools")
.option("--resume <id>", "Resume a previous conversation by ID")
.option("--reset", "Delete all conversations and start fresh")
.action(async (opts: { model: string; system?: string; apiUrl?: string; tools: boolean; resume?: string; reset?: boolean }) => {
if (opts.reset) {
rmSync(DATA_DIR, { recursive: true, force: true });
console.log(chalk.dim("All conversations deleted.\n"));
}
const baseUrl = opts.apiUrl ?? getApiUrl();
const apiKey = getApiKey();
const ctx = getStorageContext();
const messages: Message[] = [];
// Resolve or lazily create a conversation
let conversationId: string | null = null;
let isNewConversation = false;
if (opts.system) {
messages.push({ role: "system", content: [{ type: "text", text: opts.system }] });
}
if (opts.resume) {
const conv = await getConversationOp(ctx, opts.resume);
if (!conv) {
console.error(chalk.red(`Conversation not found: ${opts.resume}`));
process.exit(1);
}
conversationId = conv.conversationId;
const restored = await loadConversation(conversationId);
messages.push(...restored);
console.log(chalk.dim(`Resumed "${conv.title}" (${restored.length} messages)\n`));
}
let model = opts.model;
console.log(
chalk.dim(`Model: ${model}. Type /new, /history, /model, /exit.\n`),
);
let rl = createInterface({
input: process.stdin,
output: process.stdout,
});
let closed = false;
rl.on("close", () => {
closed = true;
});
const prompt = (): Promise<string | null> =>
new Promise((resolve) => {
if (closed) return resolve(null);
rl.question(chalk.green("> "), (answer) => resolve(answer));
});
while (true) {
const input = await prompt();
if (input === null) break;
if (!input.trim()) continue;
if (input.trim() === "/exit") {
rl.close();
break;
}
if (input.trim() === "/history") {
rl.close();
const picked = await pickConversation();
if (picked?.action === "resume") {
conversationId = picked.conversation.conversationId;
messages.length = 0;
if (opts.system) {
messages.push({ role: "system", content: [{ type: "text", text: opts.system }] });
}
const restored = await loadConversation(conversationId);
messages.push(...restored);
console.log(chalk.dim(`Switched to "${picked.conversation.title}" (${restored.length} messages)\n`));
} else if (picked?.action === "delete") {
await deleteConversationOp(ctx, picked.conversation.conversationId);
if (conversationId === picked.conversation.conversationId) {
conversationId = null;
isNewConversation = false;
messages.length = 0;
if (opts.system) {
messages.push({ role: "system", content: [{ type: "text", text: opts.system }] });
}
}
console.log(chalk.dim(`Deleted "${picked.conversation.title}"\n`));
}
rl = createInterface({ input: process.stdin, output: process.stdout });
closed = false;
rl.on("close", () => { closed = true; });
continue;
}
if (input.trim() === "/new") {
conversationId = null;
isNewConversation = false;
messages.length = 0;
if (opts.system) {
messages.push({ role: "system", content: [{ type: "text", text: opts.system }] });
}
console.log(chalk.dim("Started new conversation.\n"));
continue;
}
if (input.trim().startsWith("/model")) {
const name = input.trim().slice(6).trim();
if (name) {
model = name;
} else {
rl.close();
model = await pickModel(model, baseUrl);
rl = createInterface({
input: process.stdin,
output: process.stdout,
});
closed = false;
rl.on("close", () => {
closed = true;
});
}
console.log(chalk.dim(`Model: ${model}\n`));
continue;
}
messages.push({ role: "user", content: [{ type: "text", text: input }] });
// Lazily create a conversation on first user message.
if (!conversationId) {
const truncated = input.length > 60 ? input.slice(0, 57) + "…" : input;
const conv = await createConversationOp(ctx, undefined, truncated);
conversationId = conv.conversationId;
isNewConversation = true;
}
// Generate a proper title in the background after the first message.
if (isNewConversation) {
isNewConversation = false;
const targetConversationId = conversationId!;
postApiV1ChatCompletions({
baseUrl,
headers: sdkHeaders(apiKey),
body: {
model,
messages: [
{ role: "system", content: [{ type: "text", text: "Generate a short (max 6 words) conversation title for the user message below. Reply with the title only, no quotes or punctuation." }] },
{ role: "user", content: [{ type: "text", text: input }] },
],
},
}).then(async (res) => {
const title = (res.data as any)?.choices?.[0]?.message?.content?.trim();
if (title) {
await updateConversationTitleOp(ctx, targetConversationId, title);
}
}).catch(() => {});
}
process.stdout.write("\n");
const spinner = ora({ color: "cyan" }).start();
let firstToken = true;
// Store user message
const userText = input;
await createMessageOp(ctx, { conversationId: conversationId!, role: "user", content: userText, model });
try {
const result = await runToolLoop({
messages: messages as any,
model,
token: apiKey,
baseUrl,
headers: sdkHeaders(apiKey),
apiType: "completions",
...(opts.tools && { tools }),
onData: (chunk: string) => {
if (firstToken) {
spinner.stop();
firstToken = false;
}
process.stdout.write(chunk);
},
onError: () => {
if (firstToken) {
spinner.stop();
firstToken = false;
}
},
});
if (firstToken) spinner.stop();
if (result.error) {
console.error(chalk.red(`Error: ${String(result.error)}`));
} else {
process.stdout.write("\n");
const d = result.data as any;
const text: string =
d?.choices?.[0]?.message?.content ??
d?.output?.find?.((o: any) => o.type === "message")?.content
?.find?.((c: any) => c.type === "output_text")?.text ??
"";
messages.push({ role: "assistant", content: text });
// Store assistant message
if (text) {
await createMessageOp(ctx, { conversationId: conversationId!, role: "assistant", content: text, model });
}
if ("autoExecutedToolResults" in result && result.autoExecutedToolResults?.length) {
for (const tr of result.autoExecutedToolResults) {
console.log(chalk.dim(` [tool: ${tr.name}] → ${JSON.stringify(tr.result)}`));
}
}
}
console.log();
} catch (err: any) {
spinner.stop();
console.error(chalk.red(`Error: ${err.message}`));
}
}
});The REPL supports two slash commands: /model opens a fuzzy-search picker (or sets a model by name), and /exit quits the session.
Streaming
Tokens stream directly to stdout via the onData callback. A spinner shows while waiting for the first token, then stops as soon as content arrives. This gives immediate feedback without buffering the full response.