Config seams
Every file a block ships for you to edit — navigation, banners, onboarding, credit bundles, the chat's identity, tools and canvas — and what each one controls.
Installed components are used verbatim. What a deployment varies lives in these files: they are consumer-owned, and each explains itself in the comment it ships with, reproduced here. If a product needs something no seam offers, the change belongs in the registry as a new seam — editing an installed component forks you from every later improvement to it.
route-error
lib/error-reporting.ts
Exports RouteErrorContext, reportRouteError. source
Error-reporting seam — consumer-owned.
RouteError calls this from every route-level error boundary. The
default is a no-op on purpose: registry items never depend on an
error-reporting SDK, so a fresh install has nothing to configure and
nothing to pay for. Bind your own reporter here and every boundary
in the app starts reporting, with no component edit.
With Sentry, for example:
import * as Sentry from "@sentry/nextjs";
export function reportRouteError(
error: Error & { digest?: string },
context: RouteErrorContext
) {
Sentry.captureException(error, { tags: { scope: context.scope } });
}This runs inside a useEffect in a client component, so it must not
throw — a reporter that fails should swallow its own failure rather
than take the error screen down with it.
app-shell
lib/shell-config.tsx
Exports ShellConfig, shellConfig. source
What your product adds around the app shell, without editing
layout.tsx or components/shell/*. Every slot is optional and takes
a component with no props; one that needs data fetches it itself.
bannerTop: above the page content on every authenticated page (a trial banner, an incident notice).headerRight: the right end of the header (a notification bell, a language switcher).sidebarContent: the sidebar under the navigation (conversation history). May be an async server component; it refreshes with the page. Hiding itself when the sidebar collapses is its own call.onboardingRedirect: where a signed-in user who has not finished onboarding is sent (default/onboarding), orfalsefor a product without an onboarding step. Point it at a route outside(app): one this layout wraps would redirect to itself forever.
For example:
import { TrialBanner } from "@/components/trial/trial-banner";
export const shellConfig: ShellConfig = {
bannerTop: TrialBanner,
};lib/nav-config.ts
Exports navItems, accountItems. source
Shell navigation. Imported directly by client components, never passed from a Server Component: icons are components and cannot cross the server/client boundary.
navItems fills the sidebar; accountItems fills the user menu.
titleKeys are keys in messages/<locale>/app-shell.json. Remove the
entries whose routes you did not install; they would 404.
settings-shell
lib/settings-nav.ts
Exports SettingsTab, settingsTabs. source
Settings tab configuration — consumer-owned (same contract as
lib/nav-config.ts for the app sidebar).
settings-tabs.tsx renders exactly this list, in this order. Add a
product tab ({ value: "ai", href: "/settings/ai", titleKey: "settings.tabAI", icon: Brain }), remove one your product doesn’t
sell, or reorder — no component edit needed.
titleKey is a fully-qualified message key resolved through a
namespace-less useTranslations() (the chatConfig.starters
contract): the defaults point into this item’s settings-shell
namespace (messages/<locale>/settings-shell.json), and a tab you
add can point into any namespace you own.
Icons are Lucide components. They live here — a client-imported
config file — rather than being passed from a server layout, because
component values can’t cross the RSC boundary as props (see
nav-config.ts).
trial-banner
lib/trial-banner-config.ts
Exports TrialBannerConfig, trialBannerConfig. source
Trial banner config — consumer-owned.
hideOnPaths: locale-less route prefixes where the banner never
renders (matched against @/i18n/navigation’s usePathname, exact
or as a path-segment prefix). The default keeps it off the chat
surface — a persistent strip over a conversation is where a banner
costs the most. Empty the list to show it everywhere.
upgradeHref: where the CTA sends the user.
notifications
lib/notification-types.tsx
Exports NotificationTypeStyle, NotificationTypeMap, notificationTypes. source
Icons for the product’s own notification types. Consumer-owned:
intelligo sync never overwrites this file.
createNotification accepts any type string; a type listed here is
drawn with its icon and className (a semantic text colour), and
this map is consulted before the built-in types, so an entry can also
restyle one of those. Any other type falls back to a plain bell.
import { FileText } from "lucide-react";
export const notificationTypes: NotificationTypeMap = {
report_ready: { icon: FileText, className: "text-success" },
};Imported only by client components: an icon is a component, which cannot cross from a server component to a client one as a prop.
pricing
lib/billing-config.ts
Exports CURRENCY, CREDIT_BUNDLES, getCreditBundle. source
Client-safe billing configuration, kept apart from the server-only
lib/billing.ts so client components can read it without pulling
Stripe into the browser bundle. A bundle is validated server-side by
createCreditCheckout.
CREDIT_BUNDLES is the one-time credit packs you sell; edit or empty
it. A bundle’s name is rendered verbatim, not translated.
lib/plan-card-config.tsx
Exports PlanCardActionProps, PlanCardConfig, planCardConfig. source
What your product adds to a plan card, without editing
components/billing/plan-card.tsx.
actions: rendered under the card’s checkout button, as another way to buy the same plan (a QR payment rail, a bank transfer, a “talk to sales” link). It appears only where the checkout button does: on a priced plan that is not the workspace’s current one, for a caller allowed to change plans. It receives the plan, the selected interval, the price the card shows (major units ofCURRENCY) and the plan’s localized name.
Empty by default: the card offers card checkout alone. To offer the
QR-and-poll rail, install the payment-poll item, price each plan in
its lib/local-payment.ts, and bind its button here:
import { LocalPaymentButton } from "@/components/billing/local-payment-button";
export const planCardConfig: PlanCardConfig = {
actions: ({ plan, price, planName }) => (
<LocalPaymentButton reference={plan.slug} amount={price} label={planName} />
),
};The amount is only displayed; priceLocalPayment(reference) prices
the invoice on the server. Encode the interval in the reference
(${plan.slug}:${interval}) when the two prices differ.
billing-settings
lib/credit-bundle-config.tsx
Exports CreditBundleActionProps, CreditBundleConfig, creditBundleConfig. source
How your product sells its credit bundles, without editing
components/billing/credit-bundles.tsx.
actions: rendered under a bundle’s purchase button, as another way to buy it (a QR payment rail, a bank transfer). It receives the bundle, its price in major units with the price’s currency, and the bundle’s name. Bundles in the legacy{ credits, priceUsd }shape are sold by card only and get no actions.cardCheckout:falsehides the card (Stripe) purchase button, for a deployment that sells bundles only another way. With noactionsas well, nothing can buy a bundle.
Empty by default: bundles are bought by card alone. To sell them
through the QR-and-poll rail, install the payment-poll item, price
each bundle in its lib/local-payment.ts, and bind its button here.
A bundle’s price is what the card processor charges; the QR provider
charges in CURRENCY, so offer the button only for a bundle priced in
it, and give its reference a prefix plans do not use:
import { LocalPaymentButton } from "@/components/billing/local-payment-button";
import { CURRENCY } from "@/lib/billing-config";
export const creditBundleConfig: CreditBundleConfig = {
actions: ({ bundle, price, currency, bundleName }) =>
currency === CURRENCY ? (
<LocalPaymentButton
reference={`credits:${bundle.id}`}
amount={price}
label={bundleName}
/>
) : null,
};The amount is only displayed; priceLocalPayment(reference) prices
the invoice on the server.
feature-gating
lib/feature-catalog.ts
Exports FeatureDescriptor, featureCatalog. source
Feature catalogue — consumer-owned.
Which features exist, what to call them, and which plan unlocks them
is product data, not framework data: @intelligo-dev/billing answers
“is this workspace entitled to feature X” (hasFeature), and this
file answers “what should we say when it isn’t”.
labelKey and descriptionKey are fully-qualified message keys
resolved namespace-less (the chatConfig.starters contract), so a
feature’s copy lives in your own message files. requiredPlan is
the plan slug that unlocks it — rendered through the plan catalogue
registered by your composition root, so the name a user sees stays
in one place.
A feature missing from this map still gates correctly; it just falls back to generic copy.
lib/feature-gating-config.ts
Exports FeatureGatingConfig, featureGatingConfig. source
Feature-gating configuration — consumer-owned.
upgradeHref: where every gating surface sends the user. Defaults
to the billing settings page (the billing-settings item’s route),
which is where a plan change actually happens; point it at
/pricing if your product prefers the comparison first.
warnAtPercent / urgentAtPercent: when QuotaWarning starts
showing, and when it escalates. Below the first, it renders nothing.
payment-poll
lib/local-payment.ts
Exports priceLocalPayment. source
What a local payment buys — consumer-owned.
Card checkout is a redirect: you send the user to the processor and
they come back. Most of the world’s payment methods are not that.
QR-and-poll — QPay and SocialPay in Mongolia, PIX in Brazil, UPI in
India, PromptPay in Thailand — issues an invoice, shows a code the
user scans in their own banking app, and waits for the provider to
say it was paid. LocalPaymentModal renders that flow.
The framework does the rest: @intelligo-dev/billing issues the
invoice through the provider your composition root registered
(registerPaymentProvider, selected by PAYMENT_MODE; outside
production an unset PAYMENT_MODE is an in-memory mock), records it
against the caller’s workspace, and when the provider reports it
paid, grants what you return here once, on the server.
This file answers one question: what does reference cost, and what
does paying it grant? It is asked when the invoice is opened and again
when it is settled. Price it here, never from anything the browser
sent: an amount that arrives as an argument is an amount the buyer
chose. Return null for a reference you do not sell this way.
The default throws rather than pricing anything — a payment flow that silently no-ops is worse than one that is obviously unbound.
An implementation, over the plan catalogue:
const plan = getPlanBySlug(reference);
if (!plan) return null;
return {
price: fromMajor(plan.priceOneTime, CURRENCY),
grant: { plan: plan.slug, days: 30 },
description: plan.name,
};days makes a one-time payment buy a period: the maintenance route
returns the workspace to the free plan when it lapses, and a second
payment before then extends it. Without days the plan never ends.
or one of the pricing item’s CREDIT_BUNDLES, sold from
lib/credit-bundle-config.tsx under a credits: reference so a bundle
id never reads as a plan slug. The provider charges in CURRENCY: a
bundle priced in another currency (the card processor’s) is not sold
this way — money(bundle.price…) would hand the provider a number in
the wrong unit.
if (reference.startsWith("credits:")) {
const bundle = getCreditBundle(reference.slice("credits:".length));
if (!bundle || !("grant" in bundle)) return null;
if (bundle.price.currency !== CURRENCY) return null;
return {
price: money(bundle.price.amount, bundle.price.currency),
grant: { credits: money(bundle.grant.amount, bundle.grant.currency) },
description: bundle.name,
};
}A grant of credits is in the deployment’s billing currency. A provider
registered with its currency refuses a price in any other.
lib/payment-poll-config.ts
Exports PaymentPollConfig, paymentPollConfig. source
Polling behaviour for the QR payment modal — consumer-owned.
pollIntervalMs: how often to ask the provider whether the invoice
settled. Three seconds is a compromise: fast enough that the success
screen feels immediate, slow enough not to hammer a provider that
rate-limits status reads.
timeoutMs: when to stop asking and offer a retry instead. Five
minutes is long enough for someone to switch apps, log into their
bank, and confirm — the common case that a shorter timeout would
cut off mid-payment.
payerRoles: who in a workspace may open an invoice. A payment buys
the workspace a plan or credit, so by default only an owner may, as
with card checkout.
onboarding
lib/onboarding-steps.ts
Exports OnboardingFieldType, OnboardingSelectOption, OnboardingField, OnboardingStepConfig, OnboardingCompleteConfig, OnboardingAnswers, OnboardingConfig, onboardingConfig. source
The onboarding wizard: its steps, their fields, the completion screen
and onStepSubmit.
Copy is referenced by fully-qualified message key (titleKey,
labelKey, ctaLabelKey…), resolved through a namespace-less
useTranslations(); a step you add can point at any namespace.
The framework persists only which step the caller is on. Field
answers are kept only if onStepSubmit saves them; it runs
server-side each time the wizard advances past a step, and by default
writes displayName to the profile.
The client wizard imports this file too, so it must stay client-safe: persist through a server action, never a service imported here.
dashboard
lib/dashboard-config.tsx
Exports DashboardConfig, dashboardConfig. source
Dashboard configuration — consumer-owned, imported by client
components (so, like nav-config.ts, it may hold React/Lucide
component values that must never cross the RSC boundary as props).
The defaults render a hero from this item’s own messages, a composer that opens a new conversation, and starter chips that fill it. Every part is replaceable here rather than in a component:
hero: icon plus fully-qualified message keys for the title and subtitle. Point them at your product’s namespace to say what this workspace is actually for.starters: fully-qualified message keys (thechatConfig.starterscontract). Each renders as a chip under the composer that opens a new conversation prefilled with that text. Empty hides the row.chatBasePath: where the composer and the starters send the user. Defaults to thechatitem’s/chat; change it if your chat surface lives elsewhere, and note that the whole composer affordance only makes sense with a chat surface installed.
lib/dashboard-data.ts
Exports ResumeActor, ResumeTarget, getResume. source
Dashboard data seam — consumer-owned, read on the server.
getResume answers “what was this person in the middle of?” The
default is the most recently updated conversation, which is right for
a generic AI workspace. A product whose work has real structure —
a multi-phase assessment, a wizard, a review queue — binds its own
notion of progress here and the hero’s resume pill picks it up with
no component edit:
export async function getResume(actor: ResumeActor) {
const run = await getActiveAssessment(actor.workspaceId, actor.userId);
if (!run) return null;
return {
label: `Phase ${run.phase} of ${run.totalPhases}`,
href: `/chat/${run.conversationId}`,
};
}Return null when there is nothing to resume — the pill is hidden
rather than rendered empty.
artifacts
lib/document-patterns.ts
Exports registerDefaultDocumentPatterns. source
Title patterns that map a document to an agent label (shown on its card) and to the “Reports” tab. With none registered, every document gets the generic “AI Assistant” label.
Call this exactly once, from your composition root: calling it from a request-scoped file (an action, a page) would push duplicate entries onto the same in-memory registry.
chat
lib/chat-model.ts
Exports CHAT_MODEL_ID, getChatModel. source
Consumer-owned model resolution for the chat transport.
A fresh install has no AI provider keys, so getChatModel returns
the framework’s deterministic stub (@intelligo-dev/chat/testing): it
echoes the last user message back through a real token-by-token
stream. No network call, no API key, and the app still builds,
boots and streams a real createUIMessageStream response end to end.
CHAT_MODEL_ID is deliberately a real, registered id
(@intelligo-dev/executions/pricing) even though the stub never calls
that provider: the execution boundary bills whatever id the transport
settles with, and an id with no registered price is
refused at admission. Using a real id here means a clean install
exercises the correct pricing path — swap in your own model id the
moment you swap in a real provider below, and keep it one that is
registered.
To use a real provider: install its AI SDK package (e.g.
pnpm add @ai-sdk/anthropic in this app) and replace the body of
getChatModel — the commented example below is the whole change.
Two strings are in play and they are not interchangeable. The
registered id (anthropic/claude-sonnet-4-6) is what a turn is
admitted and billed under. The provider’s own id is often dated
(claude-sonnet-4-6-20260214) and lives in the registry entry’s
model field. Read it from there; never derive it by trimming the
prefix off the registered id, and never write it out a second time.
lib/chat-models.ts
Exports CHAT_MODELS, getChatModelOptions. source
The models the composer offers — consumer-owned.
Empty (the default) means the picker is hidden and every turn runs
on lib/chat-model.ts’s default. List two or more and the composer
shows a picker; the transport refuses anything not on the list, and
a model with a featureKey only to plans that have it. Every id
must be registered in @intelligo-dev/executions/pricing — an
architecture test holds this file to that.
export const CHAT_MODELS: ChatModelOption[] = [
{ id: "google/gemini-2.5-flash", label: "Fast" },
{ id: "anthropic/claude-sonnet-4-6", label: "Smart", featureKey: PRO_MODELS },
];Bind the same list in lib/chat-server-config.ts (models), which
is what makes the server’s answer match the composer’s offer.
lib/chat-renderers.tsx
Exports ToolPartState, CanvasRef, ToolRendererActions, ToolRendererProps, ToolActivityRow, ToolRenderer, DataRendererProps, TOOL_RENDERERS, DATA_RENDERERS, resolveToolRenderer, hasToolRenderer, hasToolCard, getToolRenderer, getDataRenderer, toolLabel, DefaultToolCard, ArtifactLinkCard, parseUnifiedDiff, FileDiffCard, ImageGenerationCard. source
How a tool, and any runtime’s data part, shows up in the chat.
This is the extension point a product uses most. Adding a tool to
the agent is one entry in TOOL_RENDERERS below — a plain object
literal in source you own; there is no register() call, nothing
runs as an import side effect, and a product package’s
card compiles against the structural props here rather than against
the AI SDK’s types.
A tool call is either a row in the activity stream — the one-line
“Searched the web ▸” the agent’s work folds into — or its own card.
A tool with no entry is a row. An entry is a component (a card), or
{ component?, label?, activity?, sources?, canvas? }:
componentdraws the call as its own card, in every state the SDK has (input-streaming→output-available, and the approval states of a gated tool). Without one the call is a row.labelis a message key naming the call in its row and in the stream’s status line, in place of the tool’s raw name (“Generating report…”).activitybuilds the call’s row when the default is not enough. The default reads the input’s first string as the target, and a call whose input has aqueryrenders as a search, its results fromsources.sourcesreads the sources the call’s output carries, which become the answer’s citations. The default readsoutput.sources({ url, title?, domain?, snippet?, index? }[]).canvassays the tool’s output also lives in the side panel (lib/chat-canvas-config.tsxdecides how akindrenders). A document the tool streams withcreateArtifactWriteropens the canvas by itself; a card’s Open reopens it.
actions is what a card can do back to the conversation: send the
next user turn (a quiz option, a suggested reply), answer a tool
that runs client-side, approve or deny a gated call, open or close
the canvas.
DATA_RENDERERS is the same seam for data-* parts — what a
Mastra workflow, an eve subagent or a product’s own turn.write
emits. The framework’s data-chat-* parts have renderers in
components/chat/data-parts.tsx; Mastra’s arrive under their own
names and render on the activity timeline.
lib/chat-canvas-config.tsx
Exports CanvasContentProps, CanvasActionContext, CanvasAction, CanvasToolbarItem, CanvasKind, fileNameOf, DEFAULT_CANVAS_KINDS, CANVAS_KINDS, resolveCanvasKind. source
Canvas kinds — how a document opened beside the chat is shown and
edited, by kind. Consumer-owned, a plain object literal.
A kind is the content component plus, optionally, its glyph and name
(on the transcript’s card and the panel’s header), a rendered
preview beside the source for content that has one, the extension
a download gets, actions in the panel’s header (run, publish) and
toolbar items that send a message about the document (“Polish the
wording”). The framework ships text (ProseMirror over markdown),
code (CodeMirror, with a live preview for HTML and SVG), sheet
(a CSV grid) and image; a product adds its own — a report kind
that renders its JSON as a designed page, a diagram kind on a graph
library — by adding an entry here:
import { ReportCanvas } from "@/components/reports/report-canvas";
export const CANVAS_KINDS: Record<string, CanvasKind> = {
...DEFAULT_CANVAS_KINDS,
report: { content: ReportCanvas, icon: ChartIcon, labelKey: "reports.kind" },
};The editors load lazily: a document that is still streaming, or one
the reader may not edit, renders on the light viewers below and
never pulls the editor bundle. The chat’s saveArtifact tool and
createArtifactWriter name a kind; the canvas looks it up here and
falls back to text for one it does not know, so nothing renders
blank.
lib/chat-config.tsx
Exports ChatHeaderRightProps, ChatAgentIdentity, ChatAttachmentsConfig, ChatCommand, ChatMention, ChatMentionsConfig, ChatConfig, chatConfig. source
Chat composition config — the consumer-owned extension point for everything a product adds to the chat surface without editing an installed component.
Every seam is optional — a fresh install ships this file with an
empty chatConfig, so the chat surface renders its baseline UI:
agent: the identity shown in the conversation header — a name and optional icon/emoji. Omit it (the default) and the header falls back to this item’s own translated “Assistant” label (messages/en/chat.json’sagent.defaultName).nameandiconare plain strings rather than message keys because an agent’s display name is product copy, not framework copy.starters: conversation-starter prompts shown on an empty conversation, as message keys — not literal strings — resolved against your app’s full message tree via next-intl’s namespace-lessuseTranslations(). E.g."support.starters.refund"resolvest("support.starters.refund")from your ownmessages/<locale>/support.json. Keeping this to keys is what keeps starters translatable without editingchat-thread.tsx.headerRight: a component the header renders on its right side, next to the History/New/Delete controls — a progress indicator, an export button reading product state forconversationId.attachments: what the composer lets the reader attach. Unset, the “+” control is hidden. Mirror the server’s policy inlib/chat-server-config.ts(attachments.accept,maxBytes,mode): the composer keeps the reader from picking a file the route would refuse, and instoredmode uploads it first.commands: slash commands the composer offers when a message starts with/. Each names a message key for its label and either inserts text or runs a callback.mentions: an@picker — files, pages, knowledge bases — with a search you bind; the pick lands in the message as@labeland in the turn’sbody.mentionsforresolveAgent.sendAutomaticallyWhen: an auto-continuation predicate, passed touseChat. Default: continue after tool results and after approval answers, which is what a tool loop and a gated tool need.
Edit this file directly to point at your product’s own components — this is consumer-owned source, not a package import:
import { ExportReportButton } from "@/components/support/export-report-button";
export const chatConfig: ChatConfig = {
agent: { id: "support-assistant", name: "Support Assistant", icon: "🎧" },
starters: ["support.starters.refund", "support.starters.shipping"],
headerRight: ExportReportButton,
attachments: { accept: ["image/png", "image/jpeg", "application/pdf"], maxBytes: 5_000_000 },
};lib/chat-server-config.ts
Exports chatServerConfig. source
Server-side chat config — consumer-owned, read by
app/api/chat/route.ts through createChatHandler.
Separate from lib/chat-config.tsx on purpose: that file is
client-facing (it binds React components — a header slot, starter
keys, an agent identity), and importing it from the Route Handler
would drag client modules into the server bundle. Everything the
transport needs and the browser must never see lives here instead.
Two fields are required — the execution boundary and a way to turn a model id into a model — because those are the two things the framework must never guess. Everything else has a default that gives a fresh install a working chat with no API keys: one agent, one prompt, no tools, a forty-message window, a truncated first line as the title.
The seam that matters most is agent.tools. Without it the model
runs with no tools, which makes the tool-renderer seam in
lib/chat-renderers.tsx unreachable — you could register a renderer
but nothing would ever call a tool for it to render. Bind tools here
and the whole path works without editing a shipped file:
import { tool } from "ai";
import { z } from "zod";
agent: {
systemPrompt: "…",
tools: ({ workspaceId }) => ({
searchDocs: tool({
description: "Search the workspace's documents",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => search(workspaceId, query),
}),
}),
},tools may be a plain ToolSet or a function of the turn (workspace,
user, conversation) — the function form is what lets a tool close
over the caller’s tenancy instead of taking it as a model argument
the model could get wrong.
Past one agent, bind resolveAgent instead of agent: it receives
the turn (the transport’s extra body fields such as agentId, the
existing conversation row) and returns the prompt, tools and model
for this turn. prepareMessages decides what the model is shown —
windowing is the default; a product that summarises pruned history
or injects profile context does it there. See ChatServerConfig in
@intelligo-dev/chat for every seam.
How the model samples is agent.generation — temperature, a token
ceiling, a tool choice, a seed, a retry count:
agent: {
systemPrompt: "…",
generation: { temperature: 0.2, maxOutputTokens: 1024 },
},Nothing is set below on purpose. A sampling default is a product
decision, not a framework one, so the transport ships none and passes
only what you write here. It is an allowlist: the options that decide
what the model writes go through, and the ones settlement depends on
— the abort signal, the finish handler, the model itself, the step
cap — stay the transport’s and cannot be overridden from here.
providerOptions is the neighbouring seam for a provider’s own knobs
(a thinking budget, say), passed through untouched.
Another runtime than streamText — a Mastra agent, an eve session —
binds streamTurn and keeps everything else: auth, the rate limit,
the gate, admission, persistence and settlement stay the transport’s
(the framework carries no helper for any AI framework; the
binding is yours, here). Mastra, natively, through @mastra/ai-sdk:
import { handleChatStream } from "@mastra/ai-sdk";
import { mastra } from "@/lib/mastra";
streamTurn: async (turn, prepared, { abortSignal }) => {
const stream = await handleChatStream({
mastra,
agentId: turn.agent.id,
version: "v7",
params: {
messages: prepared.messages,
memory: { thread: turn.conversationId, resource: turn.userId },
abortSignal,
},
});
// `usage`: settle from the agent's whole-run totals. Mastra reports
// them on its finish chunk; read them off the stream, or run
// `agent.stream()` yourself and resolve `result.totalUsage`.
return { stream, usage };
},eve: install the chat-eve item and bind eveStreamTurn from
@/lib/chat-eve — the session id and cursor live in the
conversation’s metadata, approvals and questions round-trip as eve
input responses, and its events render as this chat’s parts.
A tool that produces a document streams it into the canvas with
createArtifactWriter(turn, { kind, title }) from @intelligo-dev/chat
— append deltas, finish({ documentId }) — and any tool writes a
status line or a plan with turn.write({ type: "data-chat-status", … }).
i18n: the route lives at app/api/chat/route.ts, outside the
[locale] segment, so there is no URL segment to read a
locale from. messages below reads the NEXT_LOCALE cookie
next-intl’s middleware already sets on every page navigation, then
falls back to the configured default locale — the same “works with
nothing extra” guarantee a single-locale deployment gets everywhere
else.
chat-widget
lib/chat-widget-config.tsx
Exports ChatWidgetConfig, chatWidgetConfig. source
Chat widget config — the consumer-owned seam for the floating assistant (composition through a config you own, never a component edit).
position: which corner the launcher sits in.agent: which agent the widget’s conversations run as, when it differs from the chat page’s (chatConfig.agent).body: sent with every turn — a static product context. For per-page context passbodyto<ChatWidget>where it mounts.hideOn: pathname prefixes where the launcher is not shown (the chat page itself, auth pages).
Mount the widget once, in your app layout:
import { ChatWidget } from "@/components/chat/chat-widget";
…
<ChatWidget />