Skip to main content

Chat Integration Hooks and Package Mapping

  • Status: Proposed
  • Deciders: Abyss Team
  • Date: 2026-06-11

Table of Contents

  • Context
  • Decisions
    • Integration Contract (Types and Hooks)
  • Integration Steps (Bring Your Own LLM Function)
      1. Define typed chat request/response/state
      1. Implement your source function and wrap it with createEffect to produce a chatFx.
      1. Define state aggregation with reduceWith
      1. Mount aprovider and render chat UI
      1. Compose chat subcomponents when full-page UI is not desired
      1. Build custom UI directly on hooks
      1. Multi-conversation usage
  • Alternatives Considered
    • Build on Assistant-UI
    • Use the Underlying Code of Avery
    • Package and Path Mapping
  • Next Steps
  • Future Considerations

Context

The Abyss team wants to provide a base level of robust Conversational UX (Agentic chat) functionality to whatever components are made from Design. The implementation should be shared across web and mobile for less work, and will result in less coupling to components and uniform behavior across both platforms.

Decisions

  • Abyss will provide wrapper hooks to implement chat, producing a platform-independent API a consumer can use to bundle their LLM Gateway effect and state into a typed object that Abyss Chat components can use to deliver all the features expected of Chat - streaming, cancelation, activity indication.

  • The RxFx library (based on a subset of RxJS) will be the core utility used underneath, but the consuming app will only see imports from @uhg-abyss - except if they use any modules used from rxfx or rxjs specifically for their own modifications.

  • Abyss will build and export Chat components at different levels of pre-built composition, and export the same hooks to consumers that are used in Abyss own components, so customers could create their own components on the same backbone that the Abyss components use.

Integration Contract (Types and Hooks)

The integration contract is:

  • Consumer defines request/chunk/state types
  • Consumer creates one chatFx with createEffect(source, initialState)
  • Consumer defines state aggregation with chatFx.reduceWith(...)
  • App mounts ChatFxProvider once per conversation subtree
  • UI uses either ChatPage or chat subcomponents
  • Custom UI can call useChatFx and useChatState directly

Reference API shape:

export type ChatEffect<
TRequest,
TResponse = void,
TState = TResponse
> = EffectRunner<TRequest, TResponse, Error, TState>;
export type ChatEffectSource<TRequest, TResponse> = (
request: TRequest
) => PromiseLike<TResponse> | Observable<TResponse> | AsyncIterator<TResponse>;
export function createEffect<TRequest, TResponse = void, TState = TResponse>(
source: ChatEffectSource<TRequest, TResponse>,
initialState?: TState
): ChatEffect<TRequest, TResponse, TState>;
export function ChatFxProvider<TChatFx extends AnyChatEffect>(props: {
chatFx: TChatFx;
children: ReactNode;
}): ReactElement;
export function useChatFx<
TChatFx extends AnyChatEffect = AnyChatEffect
>(): TChatFx;
export function useChatState<
TChatFx extends AnyChatEffect = AnyChatEffect
>(): ChatStatus<TChatFx>;

Integration Steps (Bring Your Own LLM Function)

Here's how to plug in your own implementation

1) Define typed chat request/response/state

Create a local types module in consumer app code (example path: src/effects/chat/chatTypes.ts).

import type { ChatEffect } from '@uhg-abyss/shared';
export type MessageRole = 'user' | 'assistant';
export interface Message {
role: MessageRole;
content: string;
}
export interface UserMessage extends Message {
role: 'user';
}
export type Chunk = string;
export type LLMGatewayFx = ChatEffect<UserMessage, Chunk, Message[]>;

2) Implement your source function and wrap it with createEffect to produce a chatFx.

Create an effect module (example path: src/effects/chat/chatEffect.ts) and supply your LLM transport as a ChatEffectSource.

Observable-style source:

import { Observable, createEffect } from '@uhg-abyss/shared';
import type { Message, UserMessage } from './chatTypes';
const initialState: Message[] = [];
function invokeGateway$(request: UserMessage): Observable<string> {
return new Observable((notify) => {
fetch('/gateway', {
method: 'POST',
body: JSON.stringify(request),
})
.then(async () => {
notify.next('hello ');
notify.next('world');
notify.complete();
})
.catch((error) => notify.error(error));
});
}
export const chatFx = createEffect(invokeGateway$, initialState);

Async-iterator-style source:

import { createEffect } from '@uhg-abyss/shared';
import type { Message, UserMessage } from './chatTypes';
const initialState: Message[] = [];
async function* invokeGatewayIterator(
request: UserMessage
): AsyncGenerator<string, void, void> {
const response = await fetch('/gateway', {
method: 'POST',
body: JSON.stringify(request),
});
yield 'hello ';
yield 'world';
}
export const chatFx = createEffect(invokeGatewayIterator, initialState);

3) Define state aggregation with reduceWith

Attach a reducer to chatFx to aggregate lifecycle events into the state of the conversation. The immer library is shown below, but any pure (non-mutating) reducer will do:

import { produce } from 'immer';
chatFx.reduceWith(
produce((messages, event) => {
if (event.type === 'request') {
messages.push(event.payload);
messages.push({ role: 'assistant', content: '' });
}
if (event.type === 'response') {
messages[messages.length - 1]!.content += event.payload;
}
if (event.type === 'canceled') {
messages[messages.length - 1]!.content += ' (❎ User-canceled)';
}
return messages;
}),
[]
);

The event types and their corresponding payload types are available to guide a successful reducer implementation.

4) Mount a provider and render chat UI

In app entrypoint (example path: src/main.tsx), provide chatFx and render ChatPage.

import { ChatFxProvider } from '@uhg-abyss/shared';
import { ChatPage } from '@uhg-abyss/web/ui/chat';
import { chatFx } from './effects/chat';
import type { LLMGatewayFx, Message } from './effects/chat';
<ChatFxProvider chatFx={chatFx}>
<ChatPage<Message, LLMGatewayFx> />
</ChatFxProvider>;

5) Compose chat subcomponents when full-page UI is not desired

Import chat components from @uhg-abyss/web/ui/chat and compose layout as needed.

// ⚠️ The names and hierarchy of components is for ADR example purposes only.
import {
ChatHeader,
ModeSelector,
StatusBar,
ErrorBanner,
ChatTranscript,
Controls,
Composer,
TracePanel,
} from '@uhg-abyss/web/ui/chat';
<ChatFxProvider chatFx={chatFx}>
<Stack>
<ChatHeader />
<ModeSelector />
<StatusBar />
<ErrorBanner />
<ChatMessages<TMessage, TChatFx> />
<Controls />
<Composer<TChatFx> />
</Stack>
</ChatFxProvider>;

6) Build custom UI directly on hooks

For custom controls and layouts, use shared hooks with your effect type parameter.

import { useChatFx, useChatState } from '@uhg-abyss/shared';
import type { LLMGatewayFx } from './effects/chat/chatTypes';
function MinimalInput() {
const chatFx = useChatFx<LLMGatewayFx>();
const { isActive } = useChatState<LLMGatewayFx>();
return (
<button
disabled={isActive}
onClick={() => chatFx.request({ role: 'user', content: 'Hello' })}
>
Ask
</button>
);
}

7) Multi-conversation usage

Mount one ChatFxProvider per conversation instance to isolate runtime state.

<TabPanel>
<ChatFxProvider chatFx={chatFxA}>
<ChatPage />
</ChatFxProvider>
</TabPanel>
<TabPanel>
<ChatFxProvider chatFx={chatFxB}>
<ChatPage />
</ChatFxProvider>
</TabPanel>

Alternatives Considered

Build on Assistant-UI

Reference: https://www.assistant-ui.com/

This library aims to give implementers a head-start on building Conversational UX, however it is targeted at app implementers themselves, not Design Systems that want to offer customizable chats. RxFx creates a minimal abstraction layer allowing Chat Components mobile and web to share the same hooks, but dictating nothing about UX or LLM choice.

Use the Underlying Code of Avery

It is possible that Avery does things that our hook API doesn't support yet. We could learn by collaborating. But RxFx has the basic async mechanics to cover most use cases already, so it's a low-hanging fruit to start. And it maintains Abyss' independence from any particular platform, ensuring it could be used in any Conversational UX.

Package and Path Mapping

  • packages/abyss-shared Platform-independent Effect wrappers and hooks (alternately, re-exported from web and mobile packages)
  • packages/abyss-web/ui/chat Web Chat UI components
  • packages/abyss-mobile/ui/chat Mobile Chat UI components
  • products/web-chat-example - Example Chat app driven by Abyss, against LLM Gateway

The following stories will prepare the Abyss team to export these hooks, and release a tested demo chat app with provisional components.

  • Implement and Unit Test the Shared Chat Hook API
    • Integrate the Chat Demo Web App into the Abyss Repo
  • Functionally test the Chat Demo Web App
  • Implement Chat components in Abyss based on Hook API and Design (many stories - out of scope of this ADR)

  • Versioning: Version @uhg-abyss/web/ui/chat in its own library?
  • Bundle size: See if we can use the fact that Observable is built-in to modern browsers now, or use a slimmer implementation like zen-observable (used by GraphQL internally), to have the smallest bundle size delta possible.

Here’s a quick list of types and functions for the Chat API.

ExportBrief purpose
ObservableRe-export of rxjs Observable for consumers of shared chat APIs.
useFxRe-export of @rxfx/react runtime hook for effect state/commands.
useWhileMountedRe-export utility hook from @rxfx/react for mount-scoped subscriptions. (e.g. useEffect(fn, []))
traceRe-export tracing/debug operator from rxfx.
ChatEffectCore typed effect contract (request, response, error, state) with Error pinned as error type.
ChatEffectSourceType for allowed source function shapes passed to createEffect (Promise-like / Observable / AsyncIterator).
ChatStatusRead-only projection of chat runtime status fields (state, isActive, isLoading, currentError).
createEffectWrapper around rxfx createEffect that returns a typed chat effect.
ChatFxProviderReact context provider that supplies a chat effect to a subtree.
useChatFxHook to access the provided chat effect instance; throws if provider is missing.
useChatStateHook returning the read-only chat status projection for rendering UI state.
Table of Contents