React Native / Expo¶
The full path: installing the package, a client with reconnection, a chat screen with feeds,
cards with buttons, receiving and displaying media, editing a bubble during streaming, an avatar in a
WebView, and voice recording. The code is a direct continuation of the
examples/react-native/HermesChat.tsx example: everything there is the same, but
here it's assembled into ready-made steps with a discussion of pitfalls. There's no pseudocode — every
fragment compiles given the dependencies are connected.
0. What you need from the agent owner¶
| What | Example |
|---|---|
| Address | wss://hermes-go.com/mobile-ws (pilot) or your domain from MOBILE_HOST/MOBILE_PORT/MOBILE_PATH |
| Token | 64 hex characters from MOBILE_TOKEN in ~/.hermes/.env |
device.id |
a stable identifier that you choose, e.g. my-app-1 |
The token is not given in the docs — the agent owner issues it separately. Keep device.id
constant: the Hermes session (mobile:<device.id>) and the cron delivery address depend on it.
1. Installation¶
npm i hermes-mobile-client # protocol client (no runtime dependencies)
npx expo install expo-file-system react-native-webview
npm i react-native-live-audio-stream # voice recording (WAV 16 kHz)
hermes-mobile-client uses the standard WebSocket and works in Expo out of the box.
To save media you need expo-file-system; for the avatar — react-native-webview; for
voice recording — react-native-live-audio-stream (a native module, requires a dev build, not Expo Go).
2. Client with reconnection¶
Reconnection with exponential backoff is already built into HermesClient — don't write your own.
The only important thing is to react correctly to access denial (code 1008) and not hammer the server.
// src/hermes/client.ts
import { HermesClient, type ConnectionState } from 'hermes-mobile-client';
const WS_URL = 'wss://hermes-go.com/mobile-ws';
const TOKEN = process.env.EXPO_PUBLIC_MOBILE_TOKEN ?? ''; // don't hardcode the token into the repository
const DEVICE = { id: 'my-app-1', name: 'Телефон', platform: 'android', appVersion: '1.0.0' };
export function createClient(handlers: ConstructorParameters<typeof HermesClient>[0]['handlers']) {
const client = new HermesClient({
url: WS_URL,
token: TOKEN,
device: DEVICE,
maxBackoffMs: 15000, // backoff ceiling between attempts
handlers: {
...handlers,
onState: (state: ConnectionState, detail?: string) => handlers?.onState?.(state, detail),
onError: (error) => {
// auth_failed comes with fatal:true — the server closed the connection with code 1008.
// Retrying is pointless: show the user "no access" and don't reconnect.
if (error.code === 'auth_failed' || error.fatal) {
handlers?.onError?.({ ...error, message: 'Нет доступа: проверьте токен приложения' });
return;
}
handlers?.onError?.(error); // transport/offline — not fatal, reconnection is already in progress
},
},
});
client.connect();
return client;
}
Pitfalls:
auth_failedoccurs after close 1008. The SDK itself sets thefailedstate and doesn't reconnect — your job is to show the text, not to start the retry loop again.- The token is a secret. In Expo read it from
EXPO_PUBLIC_*/EAS Secrets orexpo-secure-store; don't commit it toapp.json. device.idis unique per device. Two apps with the sameidare one Hermes session and "jumping" replies.
3. Chat screen with feeds¶
The model is a single Item list: user and agent bubbles, cards and media.
The key point is upsert by id, so that both streaming and redelivery edit the same bubble.
// src/screens/ChatScreen.tsx
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FlatList, Image, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { WebView } from 'react-native-webview';
import * as FileSystem from 'expo-file-system';
import type { AgentMessage, AvatarState, MediaPayload, PromptDonePayload, PromptPayload } from 'hermes-mobile-client';
import { HermesClient } from 'hermes-mobile-client';
import { createClient } from '../hermes/client';
import { AVATAR_FALLBACK_HTML } from '../hermes/avatarHtml'; // see examples/react-native/avatarHtml.ts
const MEDIA_DIR = `${FileSystem.documentDirectory}hermes-media/`;
type Role = 'user' | 'agent' | 'card' | 'media';
interface Item {
id: string; role: Role; text: string;
kind?: string; options?: PromptPayload['options']; resolved?: string;
mediaKind?: string; uri?: string; localPath?: string; caption?: string;
}
export function ChatScreen() {
const webViewRef = useRef<WebView | null>(null);
const clientRef = useRef<HermesClient | null>(null);
const [items, setItems] = useState<Item[]>([]);
const [avatarState, setAvatarState] = useState<AvatarState>('idle');
const [connection, setConnection] = useState('idle');
const [draft, setDraft] = useState('');
const upsert = useCallback((id: string, patch: Partial<Item> & { role: Role }) => {
setItems((prev) => {
const i = prev.findIndex((it) => it.id === id);
if (i === -1) return [...prev, { id, text: '', ...patch }];
const next = [...prev];
next[i] = { ...next[i], ...patch };
return next;
});
}, []);
useEffect(() => {
const client = createClient({
onState: setConnection,
onAvatar: setAvatarState,
onTyping: () => setAvatarState('thinking'),
onTypingStop: () => setAvatarState('idle'),
// New message: append to the bubble with the same id (a long reply is cut into chunks).
onMessage: (m: AgentMessage) =>
setItems((prev) => {
const i = prev.findIndex((it) => it.id === m.id);
if (i !== -1) { const next = [...prev]; next[i] = { ...next[i], text: next[i].text + m.text }; return next; }
return [...prev, { id: m.id, role: 'agent', text: m.text }];
}),
// Streaming edit: replace the bubble's text instead of adding a new one.
onMessageEdit: (m: AgentMessage) => upsert(m.id, { role: 'agent', text: m.text }),
onPrompt: (p: PromptPayload) =>
setItems((prev) => [...prev, { id: `card-${p.id}`, role: 'card', text: p.text, kind: p.kind, options: p.options }]),
onPromptDone: (d: PromptDonePayload) =>
upsert(`card-${d.id}`, { role: 'card', resolved: d.expired ? 'expired' : d.option_id ?? 'закрыта' }),
onMedia: (m: MediaPayload) => { void saveMedia(m); },
});
clientRef.current = client;
return () => client.disconnect();
}, [upsert]);
// ... saveMedia, answer, send — see below
}
4. Cards with buttons¶
A card is a prompt frame with ready-made options. The answer is a single prompt frame with prompt_id
and option_id; you must not duplicate the choice with a separate text.
function PromptCard({ item, onAnswer }: { item: Item; onAnswer: (it: Item, optionId: string) => void }) {
return (
<View style={[styles.card, item.resolved ? styles.cardResolved : null]}>
<Text style={styles.cardKind}>{item.kind}</Text>
<Text style={styles.cardText}>{item.text}</Text>
<View style={styles.options}>
{(item.options ?? []).map((o) => (
<Pressable key={o.id} disabled={Boolean(item.resolved)}
style={[styles.option, o.style === 'danger' ? styles.optionDanger : null]}
onPress={() => onAnswer(item, o.id)}>
<Text style={styles.optionLabel}>{o.label || o.id}</Text>
</Pressable>
))}
</View>
{item.resolved ? <Text style={styles.meta}>{item.resolved === 'expired' ? 'истекла' : `выбрано: ${item.resolved}`}</Text> : null}
</View>
);
}
// answer:
const answer = useCallback((item: Item, optionId: string) => {
const promptId = item.id.replace(/^card-/, '');
const client = clientRef.current;
if (!client) return;
if (optionId === 'other') {
// clarify: the "own answer" option requires text — ask the user for it.
// Here, for brevity: text comes from your input dialog.
const text = ''; // insert input from the UI
if (!text.trim()) return;
client.answerPrompt(promptId, 'other', text);
} else {
client.answerPrompt(promptId, optionId);
}
upsert(item.id, { role: 'card', text: item.text, resolved: optionId });
}, [upsert]);
Pitfall: the other option is required to carry text. If you send option_id: "other" without
text — the server puts the card into text-wait mode and it "hangs".
5. Receiving and displaying media (expo-file-system)¶
media frames from the agent arrive with part/parts; the SDK assembles the chunks and calls onMedia
once per whole file. For images we show a data: URL, everything else we save and
hand back a path.
const saveMedia = useCallback(async (m: MediaPayload) => {
const name = m.filename || `${m.id}.bin`;
const dataUrl = m.data ? `data:${m.mime || 'application/octet-stream'};base64,${m.data}` : m.url;
let localPath: string | undefined;
try {
await FileSystem.makeDirectoryAsync(MEDIA_DIR, { intermediates: true });
localPath = `${MEDIA_DIR}${name}`;
if (m.data) {
await FileSystem.writeAsStringAsync(localPath, m.data, { encoding: FileSystem.EncodingType.Base64 });
}
} catch (error) {
console.warn('[hermes] не удалось сохранить медиа', error);
}
setItems((prev) => [...prev, {
id: `media-${m.id}-${Date.now()}`, role: 'media', text: '',
mediaKind: m.kind, uri: dataUrl, localPath, caption: m.caption || name,
}]);
}, []);
Pitfalls:
datamay be absent — then the file is given as aurllink, download it yourself.- Don't store base64 in AsyncStorage — it bloats storage; write the file via
expo-file-systemand keep the path in state. - Use
mime/filenamewhen choosing the extension.
6. Sending text and media¶
const send = useCallback(() => {
const text = draft.trim();
if (!text || !clientRef.current) return;
clientRef.current.sendText(text); // the id is generated internally and reused on retry
setItems((prev) => [...prev, { id: `u-${Date.now()}`, role: 'user', text }]);
setDraft('');
}, [draft]);
// Photo/file: base64 or a path. The SDK itself chunks it into 3 MB pieces.
async function sendPicked(uri: string, filename: string, mime: string, caption?: string) {
const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 });
const kind = mime.startsWith('image/') ? 'image' : mime.startsWith('video/') ? 'video' : 'document';
clientRef.current?.sendMedia(kind, base64, { mime, filename, caption });
}
Pitfall: kind when sending — only image / video / document / audio
(INBOUND_MEDIA_KINDS). voice belongs to outbound frames from the agent — you can't send it
up.
7. Avatar in the WebView¶
The avatar is an HTML page implementing the window.hermesAvatar contract:
window.hermesAvatar = {
setState: (state) => {}, // 'idle' | 'thinking' | 'speaking'
speak: (text) => {}, // ask to voice a line
setVolume:(level) => {}, // microphone level while recording, 0..1
};
The page URL arrives in the ready frame (avatar_url). If there's no URL — use your own page
(its minimal implementation is examples/react-native/avatarHtml.ts).
The main pitfall: the page must be initialized after ready. Before that
window.hermesAvatar doesn't exist in the WebView, and injectJavaScript silently does nothing.
const [avatarHtml, setAvatarHtml] = useState<string | null>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
// in handlers.onReady:
onReady: (ready) => {
setAvatarUrl(ready.avatar_url || null);
// If there's no URL or it's unreachable — fall back to the built-in page:
if (!ready.avatar_url) setAvatarHtml(AVATAR_FALLBACK_HTML);
},
useEffect(() => {
if (!avatarHtml && !avatarUrl) return; // ready hasn't arrived yet — the WebView isn't created
webViewRef.current?.injectJavaScript(
`window.hermesAvatar && window.hermesAvatar.setState('${avatarState}'); true;`,
);
}, [avatarState, avatarHtml, avatarUrl]);
// In the markup:
{(avatarHtml || avatarUrl) ? (
<WebView
ref={webViewRef}
source={avatarHtml ? { html: avatarHtml } : { uri: avatarUrl! }}
style={styles.avatar} scrollEnabled={false} javaScriptEnabled
// you can listen to the page's lines:
onMessage={(e) => { try { const m = JSON.parse(e.nativeEvent.data); if (m.type === 'avatar_ready') {/* contract ready */} } catch {} }}
/>
) : null}
Pitfalls:
- The WebView is created only after
ready— otherwise the state inject goes nowhere. Additionally you can wait for theavatar_readymessage from the page (onMessage). - End
injectJavaScriptcalls withtrue;— otherwise iOS may consider the promise unresolved. - If the avatar page is external (
avatar_url), it must be reachable over HTTPS — the WebView won't loadhttp://from a production build withoutApp Transport Securityexceptions.
8. Voice recording (WAV 16 kHz)¶
The protocol accepts audio only as wav or ogg, WAV — 16 kHz, mono, PCM 16 bit.
react-native-live-audio-stream returns raw PCM (base64) — it has to be wrapped in a WAV header.
// src/hermes/recorder.ts
import LiveAudioStream from 'react-native-live-audio-stream';
export function makeWavHeader(dataBytes: number, sampleRate = 16000, channels = 1, bits = 16): Uint8Array {
const byteRate = (sampleRate * channels * bits) / 8;
const blockAlign = (channels * bits) / 8;
const buffer = new ArrayBuffer(44);
const view = new DataView(buffer);
const ascii = (o: number, s: string) => { for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); };
ascii(0, 'RIFF'); view.setUint32(4, 36 + dataBytes, true); ascii(8, 'WAVE');
ascii(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
view.setUint16(22, channels, true); view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true); view.setUint16(32, blockAlign, true); view.setUint16(34, bits, true);
ascii(36, 'data'); view.setUint32(40, dataBytes, true);
return new Uint8Array(buffer);
}
let pcm: Uint8Array[] = [];
let voiceClient: { sendAudio: (b64: string, fmt: 'wav' | 'ogg', text?: string) => string } | null = null;
export function startRecording(client: { sendAudio: (b64: string, fmt: 'wav' | 'ogg', text?: string) => string }) {
voiceClient = client; pcm = [];
LiveAudioStream.init({ sampleRate: 16000, channels: 1, bitsPerSample: 16, audioSource: 6, bufferSize: 4096 });
LiveAudioStream.on('data', (chunk: string) => pcm.push(base64ToBytes(chunk)));
LiveAudioStream.start();
}
export function stopAndSend() {
LiveAudioStream.stop();
const size = pcm.reduce((n, p) => n + p.length, 0);
if (size === 0 || !voiceClient) return;
const out = new Uint8Array(44 + size);
out.set(makeWavHeader(size), 0);
let offset = 44;
for (const part of pcm) { out.set(part, offset); offset += part.length; }
voiceClient.sendAudio(bytesToBase64(out), 'wav'); // bytesToBase64 from hermes-mobile-client
}
function base64ToBytes(input: string): Uint8Array {
const clean = input.replace(/[^A-Za-z0-9+/=]/g, '');
const raw = typeof atob === 'function' ? atob(clean) : Buffer.from(clean, 'base64').toString('binary');
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return typeof btoa === 'function' ? btoa(binary) : Buffer.from(bytes).toString('base64');
}
While recording, feed the microphone level to the avatar:
webViewRef.current?.injectJavaScript(\window.hermesAvatar.setVolume(${level}); true;`)`.
Pitfalls:
- Format strictly
wav/ogg— the server rejectsmp3/aacwithunsupported_frame. - Audio ≤ 8 MB (about a few minutes). Break a long recording into several
sendAudiocalls with a new message. - If you have your own recognition — pass
textas the third argument: the agent gets both the text and the file. PuttextintosendAudio, not into a separatetextframe.
9. Pitfalls summary¶
| Symptom | Cause | Fix |
|---|---|---|
| The WebView doesn't react to state | inject before ready |
create the WebView only after ready |
| Duplicates in the feeds during streaming | edit frames shown as new |
redraw the bubble by id (onMessageEdit) |
| "No access", reconnect in a loop | auth_failed/1008 not handled |
show the text, don't reconnect |
| The card "hangs" | other without text |
send the text in the same prompt frame |
| Media wasn't saved | data is empty, only url is present |
download via url |
| Audio rejected | not wav/ogg or > 8 MB | WAV 16 kHz mono, split the recording |