- 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)
-
- Define typed chat request/response/state
-
- Implement your source function and wrap it with
createEffectto produce achatFx.
- Implement your source function and wrap it with
-
- Define state aggregation with
reduceWith
- Define state aggregation with
-
- Mount aprovider and render chat UI
-
- Compose chat subcomponents when full-page UI is not desired
-
- Build custom UI directly on hooks
-
- 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 fromrxfxorrxjsspecifically 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
chatFxwithcreateEffect(source, initialState) - Consumer defines state aggregation with
chatFx.reduceWith(...) - App mounts
ChatFxProvideronce per conversation subtree - UI uses either
ChatPageor chat subcomponents - Custom UI can call
useChatFxanduseChatStatedirectly
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-sharedPlatform-independent Effect wrappers and hooks (alternately, re-exported from web and mobile packages)packages/abyss-web/ui/chatWeb Chat UI componentspackages/abyss-mobile/ui/chatMobile Chat UI componentsproducts/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/chatin 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.
| Export | Brief purpose |
|---|---|
Observable | Re-export of rxjs Observable for consumers of shared chat APIs. |
useFx | Re-export of @rxfx/react runtime hook for effect state/commands. |
useWhileMounted | Re-export utility hook from @rxfx/react for mount-scoped subscriptions. (e.g. useEffect(fn, [])) |
trace | Re-export tracing/debug operator from rxfx. |
| ChatEffect | Core typed effect contract (request, response, error, state) with Error pinned as error type. |
| ChatEffectSource | Type for allowed source function shapes passed to createEffect (Promise-like / Observable / AsyncIterator). |
| ChatStatus | Read-only projection of chat runtime status fields (state, isActive, isLoading, currentError). |
| createEffect | Wrapper around rxfx createEffect that returns a typed chat effect. |
| ChatFxProvider | React context provider that supplies a chat effect to a subtree. |
| useChatFx | Hook to access the provided chat effect instance; throws if provider is missing. |
| useChatState | Hook returning the read-only chat status projection for rendering UI state. |