Skip to content

Getting started ​

This page installs the views, loads their styles and words, connects a scope to a host of chats and renders a first chat. The host runs in the app's backend, as in Getting started with the host; this page takes the window's end of its port.

Install ​

sh
bun add @adecore/agents-react @adecore/agent-contracts
sh
npm install @adecore/agents-react @adecore/agent-contracts
sh
pnpm add @adecore/agents-react @adecore/agent-contracts

@adecore/ui, React 19, react-dom, i18next and react-i18next are peer dependencies. Set up @adecore/ui first, since the views use its components and theme.

Import @adecore/agents only in the process that runs the chats, never in the page.

CSS ​

Import theme.css after the theme of @adecore/ui, let Tailwind 4 scan the package, and load the typography plugin for Markdown:

css
@import 'tailwindcss';
@import '@adecore/ui/theme.css';
@import '@adecore/agents-react/theme.css';
@plugin '@tailwindcss/typography';

@source '../node_modules/@adecore/ui/dist';
@source '../node_modules/@adecore/agents-react/dist';

The @source paths are relative to the CSS file. With a linked checkout and the source condition, scan src instead of dist.

Import terminal.css of @adecore/terminal to give tool output, code and diffs the terminal's colors, including the --term-ansi-* colors of command output. Without it, output keeps one color.

Words ​

Four namespaces hold the words, in English and Dutch: agent-chat, agent-prompts, agent-providers and agent-usage (AGENTS_NAMESPACES, each an AgentsNamespace). AGENTS_LOCALES.en() and AGENTS_LOCALES.nl() load all four of a language. @adecore/agents-react/locales/<language>/<namespace> exports one namespace as its default.

Some helpers call the default i18next instance directly. Add the words to that instance and pass it to UIProvider:

ts
import i18next from 'i18next';
import { AGENTS_LOCALES } from '@adecore/agents-react/locales';

await i18next.init({ lng: 'en', fallbackLng: 'en', interpolation: { escapeValue: false } });

for (const [namespace, words] of Object.entries(await AGENTS_LOCALES.en!())) {
    i18next.addResourceBundle('en', namespace, words, true, true);
}

UIProvider adds the ui namespace to the instance it is given; it does not load these four.

The host ​

Configure where drafts and preferences are kept before anything reads a store. See Persistence.

ts
import { configureChatStorage } from '@adecore/agents-react';

configureChatStorage({ namespace: 'my-app' });

An agent kind your app adds gets its mark once with configureAgentMarks. See Agent marks.

A ChatHostProvider hands the views what only your app can decide. Keep the object in a constant. A field you leave out leaves its part out. See Host adapters.

tsx
import { ChatHostProvider, type ChatHost } from '@adecore/agents-react';

const CHAT_HOST: Partial<ChatHost> = {
    notify: (toast) => showToast(toast),
    searchFiles: (scopeId, cwd, query, limit) => backend.searchFiles(cwd, query, limit)
};

createRoot(element).render(
    <ChatHostProvider host={CHAT_HOST}>
        <App />
    </ChatHostProvider>
);

A scope ​

A scope is one host of chats: an id, a transport, and a ChatClient that writes into the stores. Make it once per connection, outside render. Over a FramePort, such as a MessagePort, use portTransport:

ts
import type { FramePort } from '@adecore/agent-contracts/port';
import { ChatClient } from '@adecore/agents-react/chat';
import { portTransport, type ChatScope } from '@adecore/agents-react';
import { chatSink, watchProviderAccounts, providerSinkFor } from '@adecore/agents-react/state';

export function openScope(id: string, port: MessagePort): ChatScope {
    const frames: FramePort = {
        send: (frame) => port.postMessage(frame),
        onFrame: (listener) => {
            const receive = (event: MessageEvent): void => listener(event.data);
            port.addEventListener('message', receive);
            port.start();
            return () => port.removeEventListener('message', receive);
        }
    };

    const transport = portTransport(frames);
    const keyOf = (chatId: string): string => `${id}:${chatId}`;
    const chats = new ChatClient(transport, chatSink(keyOf), providerSinkFor(id));

    watchProviderAccounts(id, transport);

    return { id, keyOf, owns: (key) => key.startsWith(`${id}:`), transport, chats };
}

keyOf places a chat's row in the shared store and owns recognizes those keys; two scopes need keys that never overlap. providerSinkFor fills the list of CLIs. watchProviderAccounts keeps their accounts current and returns a stop function. Chat client and state covers the rest, including closing a scope.

The port from the host ​

In Electron, the main process sends the window one end of a MessageChannelMain (see Connect a window). A port cannot cross the context bridge, so the preload posts it on to the page:

ts
// preload.ts
import { ipcRenderer } from 'electron';

ipcRenderer.on('agents:port', (event) => {
    window.postMessage('agents:port', '*', event.ports);
});
ts
// In the page, before the first render.
let scope: ChatScope | undefined;

window.addEventListener('message', (event) => {
    if (event.source === window && event.data === 'agents:port' && event.ports[0] !== undefined) {
        scope = openScope('local', event.ports[0]);
    }
});

The channel name is the app's own. Without Electron, any MessagePort or socket that carries the frames will do; Implement a port shows the shape.

A first chat ​

Render the scope in ChatScopeContext, inside UIProvider, and a thread with its composer under it:

tsx
import { useEffect } from 'react';
import { Composer, Timeline } from '@adecore/agents-react/chat/ui';
import { ChatScopeContext, useChatScope, type ChatScope } from '@adecore/agents-react';
import { useChatRow } from '@adecore/agents-react/state';

function Chat({ chatId, cwd }: { chatId: string; cwd: string }) {
    const scope = useChatScope();
    const info = useChatRow(chatId, (row) => row?.info);

    useEffect(() => {
        void scope.chats.open(chatId, { cwd, runtimeMode: 'supervised' });

        return () => void scope.chats.detach(chatId);
    }, [scope, chatId, cwd]);

    return (
        <div className="flex h-full min-h-0 flex-col">
            <Timeline
                chatId={chatId}
                composer={
                    info && (
                        <Composer
                            chatId={chatId}
                            info={info}
                            focused
                            disabled={false}
                            providerFixed={false}
                            onSend={(text, extras) => void scope.chats.send(chatId, text, extras)}
                            onRetarget={(provider, selection) => scope.chats.retarget(chatId, provider, selection)}
                        />
                    )
                }
            />
        </div>
    );
}

export function ChatView({ scope, chatId, cwd }: { scope: ChatScope; chatId: string; cwd: string }) {
    return (
        <ChatScopeContext.Provider value={scope}>
            <Chat chatId={chatId} cwd={cwd} />
        </ChatScopeContext.Provider>
    );
}

Give every flex parent down to the thread min-h-0. A component that reads a chat outside a ChatScopeContext throws.

Checklist ​

  • @adecore/ui is set up, and the page imports its theme before theme.css of this package.
  • Tailwind scans @adecore/ui and @adecore/agents-react.
  • The four namespaces are on the default i18next instance, which UIProvider gets.
  • configureChatStorage runs before anything reads a store.
  • One scope per connection, made outside render, with keys that never overlap another scope's.
  • Every view sits inside a ChatScopeContext, and every flex parent down to the thread has min-h-0.

Licenses and third-party notices are listed in each package.