@elumixor/nitro-bot
v0.11.4
Published
Auto-generate an LLM chat-bot endpoint from your Nitro routes
Readme
@elumixor/nitro-bot
Auto-generate an LLM chat-bot endpoint from your existing Nitro routes.
How it works
- Install the package and enable the Nitro module.
- Mark any route that the LLM may call as a tool, by adding
export const definition = tool({...}). - Pass config (system prompt, adapters, etc.) directly to
nitroBotModule({...})innitro.config.ts.
That's it. On dev/build the module scans your routes/ directory, finds the tool markers, and mounts a chat endpoint (default /chat) that lets an LLM call those routes via Nitro's in-process $fetch.
Setup
bun add @elumixor/nitro-bot// nitro.config.ts
import { nitroBotModule } from "@elumixor/nitro-bot";
export default defineNitroConfig({
modules: [
nitroBotModule({
endpoint: "/chat",
source: "json", // "query" | "json" | "form"
field: "message", // name of the field carrying the user prompt
systemPrompt: "You are a concise assistant. Use the provided tools when they cover what the user asks.",
}),
],
});Mark routes as tools
// src/routes/weather.get.ts
import { tool } from "@elumixor/nitro-bot";
import { defineEventHandler, getQuery } from "h3";
import { z } from "zod";
export const definition = tool({
name: "get_weather",
description: "Get the current weather for a city.",
input: { city: z.string() },
});
export default defineEventHandler((event) => {
const { city } = getQuery(event) as { city?: string };
return { city, tempCelsius: 21 };
});Reusing a schema from @elumixor/nitro-client
If your default export is a call whose first argument is an object literal with body and/or query keys (the shape used by @elumixor/nitro-client's handler()), the discovery scanner pulls that schema out at build time and uses it as the tool's input — you don't need to repeat it on tool():
// src/routes/greet.post.ts
import { tool } from "@elumixor/nitro-bot";
import { z } from "zod";
import { handler } from "../utils/handler"; // nitro-client's createHandler()
export const definition = tool({
name: "greet",
description: "Greet someone by name.",
});
export default handler(
{
body: {
name: z.string().describe("The person to greet."),
excited: z.boolean().optional().describe("Use an exclamation mark."),
},
},
({ body }) => ({ greeting: `Hello, ${body.name}${body.excited ? "!" : "."}` }),
);How it works: nitro-bot parses each tool-marked route file with ts-morph. If the default export is handler(SCHEMA, fn) and SCHEMA.body / SCHEMA.query are object literals, the literal source is copied into the generated chat handler and used as the tool input — body and query are merged with body winning on collision. The z identifier (and any other identifier in the literal) must be importable from zod; non-zod helpers won't resolve. For those cases, pass input explicitly on tool() instead — explicit input always wins over auto-detection.
You get a /chat endpoint automatically:
curl -X POST localhost:3000/chat \
-H 'content-type: application/json' \
-d '{"message":"what is the weather in berlin"}'Group tools into subagents
When a bot grows past a dozen or so tools, one agent juggling all of them gets slower and picks the wrong tool more often. Group related tools under a subagent: the top-level agent (the coordinator) then sees a single delegate_to_<name> tool per group instead of every tool at once, and hands the work off.
Declare a subagent under src/bots/<name>/subagents/*.ts — the file name is the group key:
// src/bots/telegram/subagents/weather.ts
import { defineSubagent } from "@elumixor/nitro-bot";
export default defineSubagent({
// name defaults to the file name ("weather"); the description is what the coordinator routes on
description: "Weather lookups — current temperature for a city.",
systemPrompt: "You answer weather questions. Look up the city and report the temperature plainly.",
// optional: model, maxSteps overrides for this subagent's own loop
});Tag any route tool or bot-local tool into it with subagent: "<name>":
export const definition = tool({
name: "get_weather",
description: "Get the current weather for a city.",
input: { city: z.string() },
subagent: "weather", // reached only via delegate_to_weather
});How it routes:
- Coordinator runs with the shared (untagged) tools plus one
delegate_to_<name>per subagent. It calls a delegate with a self-containedtaskstring. - Subagent runs its own agent loop over its tagged tools plus the shared tools, then returns a summary to the coordinator.
- Untagged tools are shared — available to the coordinator and to every subagent. Keep cross-cutting lookups (resolve-a-person, send-a-file, remember) untagged so every layer can use them.
- The live
🔧trail nests subagent calls under the delegate that triggered them.
Declare no subagents and nothing changes — the coordinator keeps the full flat tool set (fully backward compatible).
Server-side sessions (history, auth, per-conversation context)
By default /chat is stateless: each request is one message, no history. For a real chat you want
server-owned history, auth, and a per-conversation system prompt. Define a session:
// src/chat.ts
import { defineChatSession } from "@elumixor/nitro-bot";
export default defineChatSession({
// Authenticate + resolve which conversation this turn belongs to. Reads cookies/headers off `event`.
resolve: async (event) => {
const user = await requireUser(event);
const threadId = getHeader(event, "x-thread-id") ?? "";
return { conversationId: threadId, systemPrompt: await buildSystemPrompt(threadId), user, context: { threadId } };
},
loadHistory: (resolved) => loadMessages(resolved.conversationId), // omit for stateless
save: (resolved, turn) => persistTurn(resolved.conversationId, turn),
});Point the module at it and you get a session-backed /chat (SSE when stream: true):
nitroBotModule({ sessionFile: "src/chat.ts", stream: true });Typed streaming client (keep api.chat.$post)
The mounted SSE endpoint isn't visible to @elumixor/nitro-client's generator, so it can't produce a
typed client. To keep an end-to-end-typed for await (const ev of api.chat.$post(...)), own a thin route
under routes/ and yield* runAgentSession — the session logic stays in the library, the route is
just a statically-typed shim:
// src/api/routes/chat.post.ts
import { runAgentSession } from "@elumixor/nitro-bot";
import { handler } from "@elumixor/nitro-client/server";
import { z } from "zod";
import session from "../../chat";
import { chatConfig, toolRoutes } from "#nitro-bot";
export default handler(
{ body: { message: z.string().min(1), threadId: z.string() } },
async function* ({ event, body }) {
return yield* runAgentSession({
session,
event,
message: body.message,
body, // forwarded to session.resolve so it can read body.threadId
toolRoutes,
model: chatConfig.model,
maxSteps: chatConfig.maxSteps,
});
},
);Pair it with nitroBotModule({ endpoint: false }) (you serve /chat yourself). The generator yields
{ type: "delta" | "tool" } and returns { steps }, so the client Stream is fully typed. The whole
request body is forwarded to session.resolve — carry the conversation id there (the generated streaming
client can't attach per-call headers, so the body is the typed way to pass it).
Configuration
nitroBotModule({...}) accepts:
endpoint(default"/chat")source— where the prompt comes from:"json"(default),"query", or"form"field— the name of that field (default"message")systemPromptmodel— any AI SDKLanguageModel(default"anthropic/claude-sonnet-4.6"via the Vercel AI Gateway)maxSteps(default8)
source also determines the HTTP method: "query" → GET, "json" / "form" → POST.
| source | Method | How to call |
| --- | --- | --- |
| "json" (default) | POST | curl -X POST localhost:3000/chat -H 'content-type: application/json' -d '{"message":"..."}' |
| "query" | GET | curl -G 'http://localhost:3000/chat' --data-urlencode 'message=...' |
| "form" | POST | curl -X POST localhost:3000/chat -F 'message=...' |
In each case, replace message with whatever you set field to.
Providers & env vars
model is the AI SDK's LanguageModel. It accepts two shapes:
1. Gateway string (default)
nitroBotModule({ model: "anthropic/claude-sonnet-4.6" });Routed through the Vercel AI Gateway, which abstracts over Anthropic, OpenAI, Google, etc. and gives you per-model fallback + observability. Requires AI_GATEWAY_API_KEY in your environment (or the auto-injected VERCEL_OIDC_TOKEN when deployed on Vercel).
# .env (Nitro auto-loads this in dev)
AI_GATEWAY_API_KEY=sk-...2. Direct provider
To skip the gateway, install a provider package and pass its env var:
| Provider package | Env var |
| --- | --- |
| @ai-sdk/anthropic | ANTHROPIC_API_KEY |
| @ai-sdk/openai | OPENAI_API_KEY |
| @ai-sdk/google | GOOGLE_GENERATIVE_AI_API_KEY |
| @ai-sdk/mistral | MISTRAL_API_KEY |
| @ai-sdk/groq | GROQ_API_KEY |
| Gateway string | AI_GATEWAY_API_KEY |
Then pass model as a string in the AI SDK's provider/model form — nitroBotModule({...}) config is serialized into a generated runtime file at build time, so values must be plain data. For provider instances built via createAnthropic({ apiKey: ... }) and similar, construct them inside a route or your own runtime module instead of the module config.
Where to set the env var
- Local dev: drop a
.envnext to yournitro.config.ts. Nitro auto-loads it. - Vercel: set it in Project Settings → Environment Variables, or run
vercel env add. - Other hosts: set it like any other env var. The AI SDK reads
process.envat request time, so no rebuild is needed when rotating keys.
maxSteps and tool-call limits
maxSteps (default 8) is the maximum number of model generation steps in one chat turn — not a tool-call counter. Internally we pass it as stopWhen: stepCountIs(maxSteps) to AI SDK generateText.
A "step" is one call to the model. The agent loop looks like this:
step 1: model decides → "call get_weather({city: 'Berlin'})"
↳ we run the tool, hand the result back
step 2: model decides → "call add({a: 17, b: 4})"
↳ we run the tool
step 3: model decides → final answerSo maxSteps: 8 allows roughly up to 7 sequential tool calls before the final answer is forced. Parallel tool calls within one step count as one step, so the practical limit can be higher.
The response includes the actual step count:
{ "text": "...", "steps": 2 }If the model hits maxSteps, you'll still get whatever text it produced on the last step (it can't make another tool call). Pick a value that's comfortably above your typical depth — tight limits silently truncate complex queries.
Chat platforms (Telegram, Slack, Discord, ...)
Bring any Chat SDK adapter into your module config. Everything in adapters is started alongside the HTTP /chat endpoint and routed through the same agent.
bun add @chat-adapter/telegram # or @chat-adapter/slack, @chat-adapter/discord, ...// nitro.config.ts
import { nitroBotModule, telegramAdapter } from "@elumixor/nitro-bot";
export default defineNitroConfig({
modules: [
nitroBotModule({
systemPrompt: "...",
adapters: [
telegramAdapter(), // polling (reads TELEGRAM_BOT_TOKEN)
// telegramAdapter({ webhookUrl: "https://app.example.com/telegram/webhook" }),
],
}),
],
});telegramAdapter() with no args runs in long-polling mode. Pass webhookUrl to switch to webhook mode — nitro-bot auto-mounts the receiving route at /telegram/webhook (override with webhookPath).
For other platforms (Slack, Discord, …), use the generic adapter() helper until a dedicated wrapper is added:
import { adapter } from "@elumixor/nitro-bot";
adapter({
name: "slack",
from: "@chat-adapter/slack",
factory: "createSlackAdapter",
options: { /* whatever createSlackAdapter expects */ },
webhookPath: "/slack/events",
})Why the indirection? Adapter instances need to be constructed at runtime, but nitro.config.ts is build-time code. The helpers return a serializable descriptor that nitro-bot emits into a generated runtime file.
The bot responds to DMs and @-mentions, and stays subscribed to the thread after the first reply. Available chat-adapter packages: telegram, slack, discord, teams, gchat, github, linear, whatsapp.
State defaults to in-memory (subscriptions reset on restart). Persistent state isn't yet wired through the inline config — drop into a runtime plugin if you need it before that lands.
What's next
Streaming, subagents, file attachments, and more chat-platform adapters (Slack / Discord / Teams) are on the roadmap.
