Skip to content
Pilotoo
v0.1

Mobile app with Expo

You will wire the kit into an Expo app. The mobile app depends on @pilotoo/chat-core for the session, @pilotoo/chat-protocol for the wire format and @pilotoo/chat-ui for React Native components. The few things only a phone can do (copy text, pick a photo, fetch an image) enter through ports your app fills.

  • An Expo app (the demo uses Expo with Expo Router and Uniwind).
  • A server reachable from the phone (Connecting a client).

EXPO_PUBLIC_API_URL always wins. Without it, a development build uses the host that served its Metro manifest, so a Tailscale or LAN address works without editing code, and the server’s default port.

src/features/chat/api-url.ts
import Constants from 'expo-constants';
import { Platform } from 'react-native';
const API_PORT = 4317;
function hostFromUri(uri?: string): string | undefined {
if (!uri) return undefined;
try {
return new URL(uri.includes('://') ? uri : `http://${uri}`).hostname;
} catch {
return undefined;
}
}
export function resolveApiUrl(): string {
const override = process.env.EXPO_PUBLIC_API_URL?.trim();
if (override) return override.replace(/\/$/, '');
if (Platform.OS === 'web' && typeof window !== 'undefined') {
return `${window.location.protocol}//${window.location.hostname}:${API_PORT}`;
}
const host = hostFromUri(Constants.expoConfig?.hostUri) ?? hostFromUri(Constants.expoGoConfig?.hostUri);
return `http://${host ?? 'localhost'}:${API_PORT}`;
}

For a release build, set EXPO_PUBLIC_API_URL to your server.

src/features/chat/chat-session.tsx
import { ChatSessionProvider as CoreChatSessionProvider } from '@pilotoo/chat-core';
import { createSocketIoTransport } from '@pilotoo/chat-core/socket-io';
import { useState, type ReactNode } from 'react';
import { resolveApiUrl } from './api-url';
export function ChatSessionProvider({ children }: { children: ReactNode }) {
const [transport] = useState(() => createSocketIoTransport(resolveApiUrl()));
return <CoreChatSessionProvider transport={transport}>{children}</CoreChatSessionProvider>;
}

To sign in, pass { credentials: () => token } as the second argument.

3. Fill the native ports above your routes

Section titled “3. Fill the native ports above your routes”

Mount the session and the ports in the root layout. The session sits above the routes so that opening another conversation does not drop the socket.

src/app/_layout.tsx
import { setStringAsync } from 'expo-clipboard';
import { Drawer } from 'expo-router/drawer';
import { AttachmentImageProvider, AttachmentPickerProvider, ClipboardProvider } from '@pilotoo/chat-ui';
import { resolveAttachmentImage } from '@/features/chat/attachment-image';
import { pickImageAttachments } from '@/features/chat/attachment-picker';
import { ChatSessionProvider } from '@/features/chat/chat-session';
// `useCopy` says "Copied" when this resolves, so a refusal must reject.
const setClipboardString = async (text: string) => {
if (!(await setStringAsync(text))) throw new Error('The clipboard refused the write.');
};
export default function RootLayout() {
return (
<ClipboardProvider setString={setClipboardString}>
<AttachmentPickerProvider pick={pickImageAttachments}>
<AttachmentImageProvider resolve={resolveAttachmentImage}>
<ChatSessionProvider>
<Drawer screenOptions={{ headerShown: false }} />
</ChatSessionProvider>
</AttachmentImageProvider>
</AttachmentPickerProvider>
</ClipboardProvider>
);
}

The two attachment helpers:

src/features/chat/attachment-image.ts
import { createAttachmentImageResolver, type ResolveAttachmentImage } from '@pilotoo/chat-core';
import { resolveApiUrl } from './api-url';
export const resolveAttachmentImage: ResolveAttachmentImage = (reference) =>
createAttachmentImageResolver(resolveApiUrl())(reference);
src/features/chat/attachment-picker.ts
import { MAX_ATTACHMENTS_PER_RUN, type AttachmentFile } from '@pilotoo/chat-core';
import { launchImageLibraryAsync } from 'expo-image-picker';
export async function pickImageAttachments(): Promise<AttachmentFile[]> {
const result = await launchImageLibraryAsync({
mediaTypes: ['images'],
allowsMultipleSelection: true,
selectionLimit: MAX_ATTACHMENTS_PER_RUN,
});
if (result.canceled) return [];
return result.assets.map((asset) => {
const name = asset.fileName ?? asset.uri.split('/').pop() ?? 'image';
const mediaType = asset.mimeType ?? 'application/octet-stream';
return {
name,
mediaType,
size: asset.fileSize ?? 1,
body: { uri: asset.uri, name, type: mediaType },
previewUri: asset.uri,
};
});
}

Files with a type the server does not accept are passed on, and the composer refuses them with a chip naming the file.

@expo-google-fonts/* package roots re-export every weight, and Metro copies each .ttf that is required, used or not. Import the exact faces.

src/app/_layout.tsx
import { InstrumentSans_400Regular } from '@expo-google-fonts/instrument-sans/400Regular';
import { InstrumentSans_600SemiBold } from '@expo-google-fonts/instrument-sans/600SemiBold';
import { useFonts } from 'expo-font';
const FACES = { InstrumentSans_400Regular, InstrumentSans_600SemiBold } as const;
// inside the component:
const [fontsLoaded] = useFonts(FACES);

Importing from the package root shipped 27 font files (3.2 MB) for six faces in the demo app.

  • The kit has no native dependency of its own. Clipboard, photo picker and image fetch are ports your app fills, so you choose the Expo modules.
  • Session rules (routing a run to its conversation, refusing a draft that cannot be sent, refetching after a reconnect) live in @pilotoo/chat-core and are the same on web, desktop and phone.
  • The transport is the only app-specific part: that it is socket.io and where the server is.