Bring your agent
Replace the stub model with your provider, prompt and tools — in the chat's server config, with Mastra, or around any run of your own.
A fresh install chats against a deterministic stub model. Everything that makes it your agent lives in files you own.
In the chat
app/api/chat/route.ts is two lines and stays that way:
import { createChatHandler } from "@intelligo-dev/chat";
import { chatServerConfig } from "@/lib/chat-server-config";
export const maxDuration = 300;
export const { POST, DELETE } = createChatHandler(chatServerConfig);Every turn goes through auth, the plan’s rate limit, the feature gate, conversation persistence and the execution boundary inside createChatHandler. You change what the turn does in lib/chat-server-config.ts:
import { tool } from "ai";
import { z } from "zod";
export const chatServerConfig: ChatServerConfig = {
executions,
onRequest: composeIntelligo,
model: { defaultId: "google/gemini-2.5-flash", resolve: getChatModel },
agent: {
id: "support",
systemPrompt: "You answer from the workspace's documents.",
tools: ({ workspaceId }) => ({
searchDocs: tool({
description: "Search the workspace's documents",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => search(workspaceId, query),
}),
}),
},
};Tools are native AI SDK tools. The function form closes over the caller’s tenancy, so the model is never told — and can never get wrong — which workspace it is in. Give a tool a card with a tool renderer.
Register the model
Every model id must be registered with its pricing before it runs. registerModels(DEFAULT_MODELS) in the composition root covers the built-in catalogue; pass your own entries for contracted rates or a model it doesn’t know. An unregistered id has no price, so the chat refuses the turn rather than guess a rate, and an architecture test catches literals at build time. Models and pricing covers swapping the stub for a provider model, the pricing fields and the model picker.
More seams
| Field | Use it when |
|---|---|
resolveAgent(turn) |
More than one agent — pick prompt, tools and model per conversation |
streamTurn(turn, prepared) |
Another runtime than streamText, such as a Mastra agent |
models |
Letting the reader pick among allowed models |
prepareMessages(turn, msgs) |
Windowing, summaries or injected context |
agent.generation |
Temperature, a token ceiling, a tool choice, a seed |
attachments |
Accepting files, optionally stored and signed for the model only |
deriveTitle |
A model-written conversation title |
onTurn |
Telemetry for start, complete, fail, refuse, approval and feedback |
Anything user-authored that reaches a system prompt — a stored summary, profile context — goes through sanitizeForSystemPrompt() from @intelligo-dev/core/prompt first.
With Mastra
@intelligo-dev/mastra brackets a native Mastra call without importing Mastra:
import { runWithExecution } from "@intelligo-dev/mastra";
const result = await runWithExecution(
{ executions, workspaceId, userId, capability: "support.reply" },
() => supportAgent.generate(messages), // native Mastra, untouched
);A refusal throws ExecutionRefusedError, so it can’t be mistaken for an empty result. streamWithExecution does the same for streams.
Anywhere else
A background job, a webhook, another framework: call the execution boundary directly — begin, then complete or fail.