RegistryConfig seamsMenu

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:

TypeScript
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), or false for a product without an onboarding step. Point it at a route outside (app): one this layout wraps would redirect to itself forever.

For example:

TypeScript
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.

TypeScript
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 of CURRENCY) 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:

TSX
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: false hides the card (Stripe) purchase button, for a deployment that sells bundles only another way. With no actions as 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:

TypeScript
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:

TypeScript
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.

TypeScript
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 (the chatConfig.starters contract). 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 the chat item’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:

TypeScript
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.

TypeScript
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? }:

  • component draws 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.
  • label is 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…”).
  • activity builds 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 a query renders as a search, its results from sources.
  • sources reads the sources the call’s output carries, which become the answer’s citations. The default reads output.sources ({ url, title?, domain?, snippet?, index? }[]).
  • canvas says the tool’s output also lives in the side panel (lib/chat-canvas-config.tsx decides how a kind renders). A document the tool streams with createArtifactWriter opens 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:

TypeScript
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’s agent.defaultName). name and icon are 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-less useTranslations(). E.g. "support.starters.refund" resolves t("support.starters.refund") from your own messages/<locale>/support.json. Keeping this to keys is what keeps starters translatable without editing chat-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 for conversationId.
  • attachments: what the composer lets the reader attach. Unset, the “+” control is hidden. Mirror the server’s policy in lib/chat-server-config.ts (attachments.accept, maxBytes, mode): the composer keeps the reader from picking a file the route would refuse, and in stored mode 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 @label and in the turn’s body.mentions for resolveAgent.
  • sendAutomaticallyWhen: an auto-continuation predicate, passed to useChat. 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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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 pass body to <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:

TSX
import { ChatWidget } from "@/components/chat/chat-widget";
…
<ChatWidget />