Customizing Integrations
Compose installed plugins and tools in app-owned integration modules
ChatJS separates installed registrations from application behavior. You own the domain-specific modules in integrations/. Adding, removing, or syncing plugins updates their generated inputs without rewriting these modules.
Start with a scaffold that includes these integration modules. An existing app using the older generated exports needs to adopt the modules and update its consumers explicitly. Sync does not migrate arbitrary application code.
Source ownership
Read Source Ownership before editing installed or generated files. The recipes below compose installed inputs in your app-owned integrations/ modules.
Chat lifecycle
The default composition is:
// integrations/chat/server.ts
import { installedChat } from "@/chatjs/generated/installed-chat";
import { composeChat } from "@/chatjs/core/chat/compose";
export const chat = composeChat(installedChat);
Callers import chat from @/integrations/chat/server. They do not enumerate installed plugins themselves. The composed interface exposes onCreate, afterCreate, historyFilter, publicReadFilter, links, onDeleteConversations, and onDelete.
To add app-owned behavior, put another adapter beside the installed list. For example, this adds a post-creation log:
// integrations/chat/server.ts
import { installedChat } from "@/chatjs/generated/installed-chat";
import type { ChatServerIntegration } from "@/chatjs/core/chat/contracts";
import { composeChat } from "@/chatjs/core/chat/compose";
import { createModuleLogger } from "@/lib/logger";
const log = createModuleLogger("app.chat");
const creationLog = {
afterCreate: ({ conversationId }) => {
log.info({ conversationId }, "Chat created");
return Promise.resolve();
},
} satisfies ChatServerIntegration;
export const chat = composeChat([...installedChat, creationLog]);
Installing a plugin later changes installedChat. Your local adapter remains in the composition. Put it before the spread when it should run first. Post-creation callbacks may repeat on recovery, so use an idempotent implementation when performing writes.
Composition rules
| Operation | Rule |
|---|---|
onCreate, onDeleteConversations, onDelete |
Run sequentially in declared order using the caller’s transaction. An error stops later adapters and propagates to the caller. |
afterCreate |
Run sequentially. Log each failure without exposing callback payloads and continue to later adapters. |
historyFilter |
Combine defined restrictions with AND. An empty list adds no restriction. |
publicReadFilter |
Combine defined grants with OR. An empty list returns false. |
links |
Merge results in declared order. Two adapters claiming the same chat cause an error. |
Core still owns authorization, transaction creation, lifecycle locks, pagination, and fallback title settlement. Composition does not open another transaction or turn post-creation work into a durable job. Public-read grants do not grant write access.
Use the supplied transaction for creation and deletion writes. Keep network calls outside those transactions. See Plugin API for each callback’s timing and data contract.
Billing
The default composition is:
// integrations/billing.ts
import { installedBilling } from "@/chatjs/generated/installed-billing";
import { composeBilling } from "@/chatjs/core/billing/compose";
export const billing = composeBilling(installedBilling);
billing.canSpend(ownerId) checks admission. billing.debit is either the selected adapter’s transactional debit function or undefined.
- With no billing adapter, admission is allowed and debit is absent. Usage evidence is still recorded, but receipts are not marked charged.
- With one adapter, calls preserve its method receiver and propagate its results and failures.
- With multiple adapters, composition throws. Installation also rejects conflicting billing plugins before rewriting registrations.
Do not replace an absent debit with a no-op function. A successful debit allows core to advance the receipt’s charged amount.
Add an application spending rule
Wrap the composed policy in this same file. This example restricts paid turns to an application-owned allowlist while retaining the installed adapter’s balance check and debit behavior:
// integrations/billing.ts
import { installedBilling } from "@/chatjs/generated/installed-billing";
import { composeBilling } from "@/chatjs/core/billing/compose";
const selected = composeBilling(installedBilling);
const permittedOwners = new Set(["your-owner-id"]);
export const billing = {
...selected,
canSpend: async (ownerId: string) =>
permittedOwners.has(ownerId) && (await selected.canSpend(ownerId)),
};
The wrapper continues to apply if you later install or remove the billing plugin. Replace the allowlist with your own policy when appropriate. Admission does not reserve money. Core retains usage deduplication, rounding, owner locks, and transactional settlement.
To supply an entirely app-owned billing adapter, pass it to composeBilling instead of installedBilling. That is an explicit selection: future installed adapters are not used by that composition.
Installation and removal
Sync writes static installation lists and maps under chatjs/generated/. It does not rewrite integrations/ or the composition helpers. Runtime callers keep importing the same app-owned modules as the installed selection changes.
Prefer importing the generated lists when your composition should follow installation automatically. If you directly import a plugin implementation to wrap it, your app now depends on that plugin. Removal detects supported static imports and requires you to resolve that dependency first. See Create a plugin for packaging and installation metadata.
Keep chat lifecycle and billing modules safe for Next.js server code and native EVE. Do not import React, route handlers, or Next.js request helpers into them. Browser UI retains its separate typed slot interface.
Integration points
Related modules are grouped under auth/, chat/, tools/, documents/ and providers/. Single-file domains such as billing and database remain at the top level. Import each module directly. There are no directory barrel exports, and browser-safe definitions and UI remain separate from server execution.
All runtime consumers go through app-owned composition. There is no shared plugin runtime or discovery system: these are ordinary TypeScript modules, React slots, and native framework entry points.
App-owned modules in integrations/ |
Responsibility |
|---|---|
chat/server.ts, billing.ts |
Compose lifecycle adapters and select billing policy |
chat/ui.ts, layout.ts |
Order or extend typed UI plugin lists; JSX slots still control placement |
auth/server.ts, auth/form.ts, auth/sign-out.ts, auth/signed-out.ts |
Select authentication policy, form, sign-out handler and signed-out screen separately |
navigation.ts, chat/model-visibility.ts, chat/uploads.ts |
Select navigation, model visibility and attachment hooks with explicit defaults |
tools/execution.ts, tools/ui.ts, tools/providers.ts |
Compose native tools, renderers and shared provider definitions |
tools/definitions.ts, tools/selection.ts, tools/availability.ts, tools/configuration.ts, tools/composer.ts |
Compose exposure predicates, client-safe schemas and display metadata |
documents/definitions.ts, documents/ui.ts, documents/run.ts, tools/code-executor.ts |
Compose editors and assistant actions, select saved-document execution |
routers.ts, instrumentation.ts, database.ts |
Compose native tRPC routers, startup handlers and Drizzle schema paths |
chat/session-profiles.ts, cleanup.ts |
Select persisted-session profiles and scheduled cleanup tasks |
plugin-catalog.ts, tools/catalog.ts |
Client-safe installation names and document kinds |
providers/gateway.ts, providers/storage.ts, providers/sandbox.ts |
Wrap the installed native provider factories |
Each file imports the matching generated input and exports the app’s selection. Gateway, storage and sandbox use their installed implementation modules directly rather than a generated registry. Their lifecycle and credentials remain in the provider implementations and runtime callers.
Add or wrap tools
Add a native EVE definition to the object in integrations/tools/execution.ts, retaining the installed inputs and workflow-name guard. Add its renderer to integrations/tools/ui.ts. An explicit object property after a spread can replace an installed definition or wrap an installed renderer. Installed descriptors still reject duplicate names; dynamic tools cannot shadow native workflow names.
Add its shared definition to integrations/tools/definitions.ts for catalog membership, selection (selectable: true), and display metadata. Use tools/composer.ts when you need a custom icon. Keep these modules browser-safe. The catalog cannot import the server tool map to infer names: that would pull provider implementations and credentials into the client graph. There is no automatic discovery of arbitrary app-owned tools.
Shared tool definitions
Keep schemas and display metadata in a browser-safe definition.ts. Server execution and the renderer import the same definition. Do not export execution functions, environment readers, or server SDKs from this module.
// integrations/tools/definitions.ts
import { installedToolDefinitions } from "@/chatjs/generated/tool-definitions";
import { validateToolDefinitions } from "@/chatjs/core/tools/tool-definition";
import { summarizeNotes } from "@/tools/local/summarize-notes/definition";
export const toolDefinitions = validateToolDefinitions([
...installedToolDefinitions,
summarizeNotes,
]);
This list declares metadata, not execution. Register the native tool in integrations/tools/execution.ts and its validated renderer in integrations/tools/ui.ts. These separate imports keep execution out of the browser graph. Duplicate definition names fail at composition. Installed definitions also have a generated type check against their descriptor’s registration name.
Document kinds
A document bundle owns three entry points:
| Entry | Responsibility |
|---|---|
definition.ts |
Browser-safe kind, content schema, create/edit operation names |
tool.ts |
Native EVE tools with authorization and persistence |
document.tsx |
Editor, copy behavior, comparison support, assistant actions |
The app composes definitions in integrations/documents/definitions.ts and editors in integrations/documents/ui.ts. Add a kind such as diagram by adding its definition and view to those lists. You do not edit a core enum or add kind-specific branches. See Authoring tools for the bundle contract.
Saved revisions store the kind as a string. Removing a bundle preserves its saved content and renders a read-only source fallback with a reinstall notice. Existing authorized reads still work. Tool and manual writes require the installed definition and pass its content schema. Reinstall the same kind to restore its editor. The content remains text, with a core size limit of two million characters. Structured formats can validate a textual representation.
Wrap a document’s Body in integrations/documents/ui.ts using ordinary React composition while retaining the other properties. A direct import of its installed source creates an explicit removal dependency, just like a tool renderer wrapper.
Uploads outside the composer
integrations/chat/uploads.ts selects a headless upload hook. It returns enabled, busy, uploadQueue, errors, and enqueue(files). The hook owns authentication checks, file preparation, the attachment limit, ordered delivery, and queue locking. It does not render controls, show toast notifications, or install DOM handlers.
"use client";
import { attachmentUploads } from "@/integrations/chat/uploads";
import type { AttachmentUploadInput } from "@/chatjs/core/installation/contracts";
export function ProjectFileInput(props: AttachmentUploadInput) {
const files = attachmentUploads.useUploads(props);
return (
<section>
<input
type="file"
multiple
disabled={!files.enabled || files.busy}
onChange={(event) => {
const selected = [...(event.currentTarget.files ?? [])];
event.currentTarget.value = "";
void files.enqueue(selected);
}}
/>
{files.errors.map((error) => (
<p key={error} role="alert">
{error}
</p>
))}
</section>
);
}
Pass the current attachment count and an onUploaded callback. An optional onError callback lets your app display errors elsewhere. The chat composer separately uses chatjs/core/uploads/use-attachment-picker.ts for picker, drop, and paste events. The omitted-plugin default reports enabled: false and rejects direct enqueue calls. There is no separate progress or cancellation contract.
Wrap UI
For a renderer, retain the installed map and replace one entry with a normal React component:
// integrations/tools/ui.tsx (rename the scaffold's .ts file when adding JSX)
import { installedToolUi } from "@/chatjs/generated/ui";
import { defineToolRenderer } from "@/chatjs/core/tools/define-renderer";
import {
wordCountInput,
wordCountResult,
} from "@/chatjs/tools/word-count/schemas";
const WordCount = installedToolUi["tool-wordCount"];
export const ui = {
...installedToolUi,
"tool-wordCount": defineToolRenderer({
inputSchema: wordCountInput,
outputSchema: wordCountResult,
render: (props) => (
<section aria-label="Word count">
<WordCount {...props} />
</section>
),
}),
};
This example requires the word-count renderer to be installed. Tool renderers must use defineToolRenderer: a plain React wrapper lacks the validation marker and is intentionally rejected when rendering persisted messages. A wrapper that imports a concrete plugin directly makes removal depend on updating that import. Keep shadcn primitives in components/ui/ close to upstream; compose application UI above them.
Exclusive integrations reject multiple selections. Hook implementations are selected at module scope, never by looping through hooks during rendering. Session profiles must have unique authenticator identities; cleanup tasks must have unique IDs, including core tasks.
Native entry points and migration
Next.js routes, EVE entry files and database migrations keep their native file conventions. Installing those files can activate behavior without a composition list. chat-js add --dry-run, chat-js status, and chat-js remove --dry-run distinguish declared integration modules from native entry paths and ordinary framework source. The inventory matches known file conventions without executing source. Exports and framework configuration still determine actual activation. Integration modules do not intercept framework discovery. Removing a plugin retains its database schema history.
Apps with the older generated exports or composition/custom/ files must explicitly adopt the new modules and update callers before sync. Move local tool and renderer registrations into integrations/tools/execution.ts and integrations/tools/ui.ts; preserve their behavior rather than overwriting them with scaffold defaults. After updating consumers, remove the obsolete generated behavior files named by the layout error, then run sync and app checks. Preserve receipts and user source. Sync does not rewrite arbitrary user source.
When automatic installation conflicts, chat-js add <item> --files-only exports raw source and connection metadata into a temporary review directory. Review and copy selected files, adapt aliases/dependencies, and connect them through these app-owned modules. The export does not install or activate the plugin. See add.
If your scaffold has flat integration files, move them into the domain paths listed above and update their imports, including imports between integration modules. Preserve your implementations and wrappers. Sync does not relocate or overwrite these application files.