Skip to content
ChatJS
Esc
↑↓navigate↵open⌘Jpreview
On this page

Plugin API

Typed server and client contracts for optional ChatJS capabilities

Plugins own optional behavior, storage, and UI. Core owns authentication, chat identity, lifecycle coordination, dispatch, and recovery. Core can name a capability such as Projects and declare its optional input fields without importing its implementation.

Start with Create a plugin for installation and packaging. Use Customize plugin integrations to add app-owned chat and billing behavior that survives later installation. Installed source lives in chatjs/plugins/<id>/, with thin framework adapters under app/ and agent/.

Entry points

Keep server and browser import graphs separate:

Entry point Contract Contributions
server-plugin.ts ChatPlugin tRPC routers, instrumentation
chat.ts ChatServerIntegration Transactional chat integration, safe for EVE and Next.js
signed-out.tsx React component Optional screen for signed-out chat visitors
session-profile.ts SessionProfile Native authenticator, bounded model, and instructions
cleanup.ts CleanupTask Bounded cleanup with progress and pending inventory
billing.ts UsageBilling Spending admission and transactional debits, safe for EVE and Next.js
auth-server.ts AuthenticationIntegration Better Auth plugins, trusted origins, auth route matching
navigation.ts () => boolean Whether core handles modified links as app navigation
auth-form.tsx, sign-out.ts AuthenticationUi Authentication form wrapper and sign-out handler, imported independently
client-plugin.ts definePlugin Typed UI slots, uploads, model visibility

Keep chat.ts and billing.ts free of web routers and Next.js request helpers: native EVE loads this entrypoint too.

Factories return concrete types with satisfies. Sync generates static imports and registrations from the descriptor without executing server factories during installation.

import type { ChatPlugin } from "@/chatjs/core/installation/plugin";
import { projectRouter } from "@/chatjs/plugins/projects/router";

export const projects = () =>
  ({
    id: "projects",
    routers: { project: projectRouter },
  }) satisfies ChatPlugin;

Projects creation and recovery

Core declares optional projectId fields in creation and history inputs. Supplying a project when Projects is not installed fails explicitly. Forks cannot specify a new initial project because they share their source logical chat.

chat.onCreate({ tx, chatId, ownerId, projectId }) runs only when core inserts a new logical chat. Projects validates ownership under a lock and inserts membership using the supplied reservation transaction. Throwing rolls back both the chat reservation and membership. Keep external calls and native dispatch out of this callback.

A retry checks the original message, attachments, model, tool selection, and source. It ignores a changed initial project choice and does not invoke the creation callback again. Moving a chat uses an explicit assignment mutation. Multiple model responses share their logical chat’s membership.

The full original request is retained for dispatch and recovery. Its browser storage scope contains only a key and label. Projects uses project:<id> to preserve existing saved drafts. Current membership is never used to reconstruct a pending request. Recovering a definitively rejected project request into New Chat preserves the message, tools, and models under a fresh operation ID without the rejected project selection.

These are explicit core contracts. There is no registry of arbitrary creation fields or schema-merging protocol.

After creation

Use chat.afterCreate({ ownerId, conversationId, message }) for optional work after a successful root creation response. It runs outside the creation transaction through Next.js after, using a bound conversation from the initial chat or response group. Forks do not invoke it.

Recovery requests may invoke the callback again. Make your contribution idempotent and recheck ownership and state before writing. Core catches each contribution’s failure so later contributions still run. This callback does not guarantee durable execution. AI Titles uses it for guarded title enrichment.

Explicit chat mutations

Use updateOwnedChat from @/lib/db/update-owned-chat for existing-chat metadata changes. Core resolves a logical chat or legacy conversation ID, verifies ownership and a bound session, and holds the family lock used by deletion. Unavailable chats return null before the callback runs.

import { eq } from "drizzle-orm";
import { updateOwnedChat } from "@/lib/db/update-owned-chat";
import { eveChatProject as membership } from "@/chatjs/plugins/projects/db/project-schema";

// ownerId and chatId come from the authorized procedure.
await updateOwnedChat({ ownerId, chatId }, async ({ tx, chatId }) => {
  await tx.delete(membership).where(eq(membership.chatId, chatId));
  return { chatId };
});

Use the supplied transaction for plugin validation and writes. A successful result updates the chat timestamp. Return null before writing when validation rejects the mutation. Throw on failure to roll back the transaction. Projects owns the project validation and membership writes.

This helper is for logical-chat metadata. Conversation-specific operations that must not touch the chat timestamp need their own appropriate access contract.

History and navigation

chat.historyFilter(input, surface) returns a Drizzle SQL restriction or undefined. Core combines it with ownership, search, and cursor predicates before ordering and limiting. Projects excludes assigned chats from the ordinary sidebar and filters project pages by projectId. Search can include all chats.

chat.links(ownerId, chatIds) returns batched { href, parentHref } entries for authorized chats. Core uses them for history links and navigation after deletion. Ordinary chats use /chat/<id> and /. Two plugins claiming navigation for the same chat throw rather than depending on installation order.

History predicates combine with AND. Return undefined when your restriction does not apply. Filtering an already-fetched page breaks pagination.

Public reads

chat.publicReadFilter() returns a Drizzle predicate over the core conversation table. Installed predicates combine with OR. With no contribution, the result is false. File and document readers combine this grant with ownership, bound state, and their resource-specific checks. This contribution never grants write or workspace access.

Sharing contributes the public visibility predicate. Its public transcript route belongs to the plugin and rechecks visibility after reading the native snapshot. Core retains the visibility column and resets it during deletion.

Client actions receive the logical chat id and, when available, the exact conversationId. Use the latter for branch-specific sharing. Header menus omit it when there is no transcript to share. Plugin actions own their dialogs and preserve the menu while a dialog is open.

Deletion

Callback When it runs Typical data
onDeleteConversations(tx, { conversationIds, sessionIds }) Before selected conversations are tombstoned Feedback votes and session-owned suggestions
onDelete(tx, chatId) When the final session of a logical chat is deleted Project membership

Both callbacks run inside the core deletion transaction under its lifecycle lock. A failure rolls back database cleanup. Use the supplied transaction. Removing a project detaches its chats without deleting their sessions.

Usage billing

Declare billing: { export: "creditsBilling" } in the descriptor and export a UsageBilling object from billing.ts. Sync allows at most one billing contributor. The generated installed-billing.ts contains only the typed installedBilling adapter list. App-owned integrations/billing.ts selects the policy with composeBilling. Callers use billing.canSpend(ownerId) and the optional billing.debit. Without an adapter, admission resolves to true, debit is absent, and core leaves receipts uncharged.

canSpend(ownerId) checks admission; it does not reserve funds. debit(tx, { ownerId, cents }) applies a positive whole-cent debit using the supplied transaction. Core has already locked the registered owner, deduplicated evidence, and calculated the unpaid rounded turn total. A failure rolls back the receipt, debit, and settlement marker together. Do not open another transaction or call external services from debit.

Core owns durable usage evidence, per-turn rounding, chargedCents, and reconciliation. The plugin owns balance storage and spending policy. Guest Chat sessions do not enter account usage billing. Locking the core user row keeps receipt ingestion serialized even when no billing table exists.

The Credits plugin uses this contract. Native EVE imports the billing entry directly; keep routers and Next.js request helpers in the server entry. Billing installation does not backfill old usage automatically; newly reconciled receipts can still settle an existing turn’s unpaid total.

Client slots

Declare a client plugin once at module scope. Import typed slot components from @/components/chat-slots, and definePlugin and slot from @/chatjs/core/ui/slots:

"use client";

import { ChatActions } from "@/components/chat-slots";
import { definePlugin, slot } from "@/chatjs/core/ui/slots";
import { ProjectChatActions } from "./ui/chat-contributions";

export const projectsClient = definePlugin({
  id: "projects",
  slots: [slot(ChatActions, ProjectChatActions)],
});

slot(SlotComponent, PluginComponent) creates a binding without changing the slot or rendering the component. The slot reference carries its prop type. A component with incompatible props fails type checking. The application renders <ChatActions chat={chat} /> where those actions belong. Generated client lists contain references to installed declarations, without invoking factories or patching application JSX.

Slot Purpose
ComposerAttachments Attachment controls before app-owned tool controls
ComposerExtensions Additional composer controls after tool controls
SettingsLinks Additional settings navigation links
ModelSettingsLink Model settings shortcuts with surface: "sidebar", "settings", or "picker"
AccountSummary Content beneath the signed-in user name
SidebarSections Sidebar content
SidebarHeaderItems Fixed sidebar menu items with server-resolved ownerId
ChatActions Menu actions receiving { chat: { id, title, state, conversationId? } }
ChatHeaderActions Header controls receiving the same chat context
ChatBreadcrumbs Context receiving { chatId }
MessageActions Assistant-message actions
MessageResponses Response content associated with user messages
ChatSuggestions Suggestions beneath the last editable message
LayoutEffects No-prop global layout components, from @/components/layout-slots

Plugin components own fetching, mutations, loading, errors, dialogs, and appropriate menu or sidebar markup. Edit the installed component or bind an ordinary wrapper component to add providers, styling, or behavior. Put app-specific JSX beside slots in the layout. No string-based override record is required.

ChatPlugins scopes installed chat components to the chat layout. LayoutPlugins renders a separate list for global layout behavior. A nested Plugins provider replaces the installed UI set for its descendants. Slots without bound components render nothing. Duplicate plugin IDs throw. Keep declarations and wrappers at module scope to preserve component identity.

Lazy components work with React.lazy and normal Suspense boundaries. The declaration is imported eagerly, but a dynamic component loader runs when that component renders. Keep expensive dialog content behind its trigger. Client declarations must not import server implementations. Server-rendered children can pass through a client provider, but the slot registry itself is a client API.

The descriptor’s client fields still declare installation capabilities. client.layout: true selects the global list. Other client declarations enter the chat list. A declaration with both layout and chat UI capabilities enters both lists, so keep its imports appropriate for both scopes. Upload handling and model visibility retain their dedicated generated hooks. Runtime disable switches are not required.

For account summaries, declare client.accountSummary: true and bind a component to AccountSummary. Set query meta: { refreshOnTurnEnd: true } to refresh when a turn completes, fails, or is cancelled. With no bound component, the sidebar renders only the user name.

Model visibility

Declare client.modelVisibility: true in your descriptor and include useModelVisibility in your client declaration. Sync installs one hook with the type (defaultEnabledIds: ReadonlySet<string>) => ReadonlySet<string>. Core calls it unconditionally from the model provider. Only one plugin can supply this contribution, so two providers fail installation before registrations change.

Your hook owns fetching and applying user preferences. Return a stable set when its inputs are unchanged. Core filters its available catalog against the returned IDs, so an unknown ID cannot add a model. With no provider installed, the generated hook returns core defaults unchanged and performs no preference request. This contribution changes picker visibility, not authorization, capability checks, or guest session policy.

Message actions

The MessageActions slot receives { conversationId, messageId, disabled }. Core renders it for editable assistant messages; each plugin owns its queries and mutations. Feedback uses a conversation-specific write with a lock and state recheck, because voting must not change the logical chat’s update timestamp.

Response content

Declare client.chat: true and bind your component with slot(MessageResponses, YourResponses) in a definePlugin client declaration. Import MessageResponses from @/components/chat-slots. Like message actions, the component receives { conversationId, messageId, disabled }, typed as ChatMessageProps. Return null when your contribution does not apply to that user message. Respect disabled for interactions and own your fetching and recovery state.

integrations/chat/ui.ts composes installed declarations and the local Parallel Responses declaration in display order using the shared Plugins provider. Each is additive. Ordinary version navigation and assistant action buttons remain separate placements. Response content runs inside the account transcript’s logical-chat context. Public sharing and guest transcripts do not mount this placement.

Composer and submission composition

EveComposer accepts a modelPicker React node. Omit it for the default single-model picker. The slot inherits the composer’s disabled, read-only, and upload locks. Supply modelIds alongside a custom picker so attachment validation checks every selected model. Selection state and model counts belong to the caller. ComposerAttachments and ComposerExtensions contribute to the Add menu and do not replace the picker.

Guest Chat supplies a disabled single-model picker. Account creation, edits, and follow-up messages supply ParallelResponseModelPicker. The shared ModelSelector primitive shows its multiple-selection toggle only with allowMultiple. The account picker also checks config.features.parallelResponses.

ComposerSubmission exposes submit(), pending, error, and an optional preview React node and recovery React element. NewConversationView places those nodes without reading saved requests. The submission controller owns operation identity, persistence, dispatch, and recovery. Retry stays inside the recovery implementation because an unconfirmed creation and an unconfirmed message delivery have different retry rules.

These are React composition seams, not global registration hooks for arbitrary submission handlers. Parallel Responses currently uses a local response contribution and still has core response-group persistence and dispatch. It is not yet an independently installable plugin. Extracting those responsibilities requires preserving reservation, replay, and deletion transactions.

Suggestions

When you contribute to ChatSuggestions, it receives sessionId, conversationId, native events, lastMessage?: { role, metadata?: { turnId?: string } }, disabled, and onSelect. It renders beneath the last editable message when transcript actions are available. Handle a missing lastMessage, metadata, or turnId without assuming completion identity. Your plugin owns completion matching, storage reads, and polling. The callback returns the selected text to core’s existing send/compare flow.

Follow-up Suggestions uses native EVE hooks for generation and the per-conversation cleanup callback for session-owned records. Its claim-before-generation rule and core usage accounting survive extraction; no new event bus or generic mutation wrapper is involved.

Authentication and desktop navigation

Declare authentication: { export: "desktopAuth" } for an AuthenticationIntegration exported from auth-server.ts. Core adds its Better Auth plugins and trustedOrigins, and consults isAuthRoute(url) before redirecting page requests. Match routes narrowly: this predicate lets unauthenticated visitors reach the page and keeps signed-in users on authentication handoff pages. It does not replace endpoint authentication or authorize data access.

Declare authenticationUi: { form: "DesktopAuthForm", signOut: "signOutDesktop" } in the descriptor. Export the form from auth-form.tsx and the handler from sign-out.ts. Sync imports them independently, so signing out does not load the login form or layout. The form receives mode: "login" | "register" and the normal web form as children. Render the children when your integration does not apply. The asynchronous sign-out handler returns true after handling sign-out, false to use core web sign-out, and rejects on failure. Core clears cached account data and navigates only after success.

Declare navigation: { export: "desktopNavigation" } for a function exported from navigation.ts. Return true when core should handle modified internal links as normal app navigation. With no contribution, core preserves browser modifier behavior. Keep this entry independent of auth UI and client declarations to avoid an import cycle through the shared link component.

Each of these slots accepts one provider. Without contributions, generated registrations add no auth plugins or origins, recognize no extra auth routes, render the web form unchanged, and defer to web sign-out. Desktop also uses the existing layout contribution for renderer session synchronization. It introduces no plugin database schema.

Native hooks and instructions

Use the existing EVE hooks, tools, and instruction adapters under agent/. Projects resolves the authorized conversation before reading membership and preparing instructions. Lookup failure stops the turn rather than silently omitting instructions. The instruction resolver reads the prepared state.

Storage and installation

Declare plugin schema paths in databaseSchemas. Drizzle composes installed schemas through installed-database.ts. Keep table names and migration history when extracting an existing capability. Adding or syncing a plugin generates registrations but does not apply migrations.

{
  "contractVersion": 1,
  "id": "projects",
  "kind": "plugin",
  "databaseSchemas": ["chatjs/plugins/projects/db/project-schema.ts"],
  "server": { "export": "projects", "routers": ["project"] },
  "chat": { "export": "projectsChat" },
  "client": { "export": "projectsClient", "chat": true }
}

The complete descriptor lists every installed file. Use distinctive filenames such as project-schema.ts to avoid registry import-rewriting ambiguity with host files.

Generated modules register routers, database schemas, instrumentation, layout, uploads, account summaries, billing, and chat contributions. Edit plugin declarations and descriptors, then run sync. Verify installations both with and without the plugin. Core must not import optional plugin storage or implementation directly.

Signed-out chat

Declare signedOut: { export: "GuestChatScreen" } and export a no-prop component from signed-out.tsx. Only one plugin may own this screen. Core renders it at the signed-out chat entrypoint. Without it, core redirects to /login.

The screen owns its conversation state and policy. It can reuse core presentation components without making the account model picker or account conversation APIs depend on guest behavior. EveComposer is text-only by default. Account callers supply the optional capabilities object containing attachment state, selected tool, and tool-change callback; text-only screens do not create those states or mount upload controls.

Native session profiles

Declare sessionProfile: { export: "guestSessionProfile", authenticator: "chatjs-disposable-guest" }. Export a SessionProfile from session-profile.ts, which must be safe to load in the native EVE graph. It contains authenticator, authenticate(request), resolveModel(), and instructions. Shared SessionAuthentication and SessionModel types in chatjs/core/session/contracts.ts define the returned identity and model settings independently of account implementations. A profile resolves its own model without accepting an account model-selection argument.

The channel composes installed authenticators. Model and instruction selection uses the persisted initiator’s authenticator, never reconnect credentials or caller-supplied attributes. Unknown or removed profiles throw. Account hooks, tool execution, billing, and workspace access require the account authenticator explicitly. A profile does not grant those account capabilities.

Authenticator names must be unique and cannot use the reserved account identity chatjs-gateway. Generated registration checks the runtime identity against the descriptor. Keep validation and bounds inside the profile, including at the provider call.

Scheduled cleanup

Declare cleanup: { export: "guestCleanup", id: "guest-chat" }. Export a CleanupTask with a matching id and run() from cleanup.ts. Task IDs must be unique. Core reserves orphanedAttachments for its storage sweep.

A task returns { deletedCount, retiredCount?, pendingCount?, skipped } for one bounded sweep. deletedCount reports payload deletion; optional retiredCount reports ended or expired sessions whose payloads remain under provider retention. Retirement is not proof of erasure. Counts must be nonnegative safe integers. Omit pendingCount only when the sweep has no remaining work. Skipped tasks, pending work, invalid counts, and errors make the job unsuccessful. Independent tasks still run, and responses contain safe error messages.

The authenticated cron endpoint and hosted draining script consume this common result. Plugins own inventory and retry policy. Core retains the native deletion fences and purge verification for providers that support purge. Managed guest retention uses terminal-session evidence instead. The Vercel cron drains progressing batches within a time and batch budget; incomplete work remains retryable. The cleanup contribution does not add a scheduler framework.

All installed instrumentation registrations start independently. One exporter’s failure still propagates to Next.js, but cannot prevent another plugin’s local cleanup registration from starting.

Last updated on Nov 8, 2026

Was this page helpful?