TypeScript SDK — API reference¶
The hermes-mobile-client package is a client of the Hermes Mobile Adapter v1 protocol for apps and
Node scripts. It knows nothing about the UI: only transport and callbacks. There are no runtime
dependencies — the standard WebSocket is used.
Source: sdk/typescript/hermesClient.ts. Frame specification: ../../protocol/index.md.
Installation¶
The package is built in two formats (the exports field):
// ESM — React Native, Expo, web, bundlers, modern Node
import { HermesClient } from 'hermes-mobile-client';
// CommonJS — plain Node scripts
const { HermesClient } = require('hermes-mobile-client');
Requirements and compatibility:
| What | Value |
|---|---|
| Node | >= 22 (the engines field) |
| Platforms | React Native / Expo, browser (web), Electron, Node |
| Dependencies | none (the global WebSocket is used; in Node 22 it is built in) |
| Types | .d.ts bundled (dist/esm/hermesClient.d.ts) |
| Package version | 1.0.1 |
The token is a secret
token is the MOBILE_TOKEN on the agent owner's side. Do not embed it in
public bundles (web/Electron) and do not commit it: the token grants access to the agent's
sessions, tools and memory. On the web, serve it from your own backend.
Who configures access
The device list (allowlist) and pairing are configured by the agent owner (hermes pairing).
The SDK does not set them: if a device is not approved, the gateway will close the connection with code 1008,
and the client will move to the failed state with the auth_failed error (retrying is pointless).
Class HermesClient¶
Constructor¶
ClientOptions:
| Field | Type | Req. | Default | Meaning |
|---|---|---|---|---|
url |
string |
yes | — | Gateway address, ws://host:8787/ws or wss://host/ws. The token is not passed in the URL. |
token |
string |
yes | — | Application token (MOBILE_TOKEN). Sent only in the hello frame. |
device |
DeviceInfo |
yes | — | The device as it is seen in Hermes (pairing, allowlist, cron). |
user |
{ id?: string; name?: string } |
no | {} |
The user — carried in the hello frame. |
handlers |
HermesClientHandlers |
no | {} |
Event callbacks (see below). |
maxBackoffMs |
number |
no | 15000 |
Ceiling of the pause between reconnection attempts, ms. |
There is no openTimeout in the TS SDK
Unlike the Python SDK (which has open_timeout), the TypeScript constructor does not
accept openTimeout: the socket open timeout is delegated to the runtime's global WebSocket.
There is no ClientOptions field with that name.
DeviceInfo:
| Field | Type | Meaning |
|---|---|---|
id |
string |
Device identifier (used in mobile:<id>). |
name |
string |
Display name. |
platform |
string |
ios / android / web / another label. |
appVersion |
string |
Application version. |
Properties and utility methods¶
| Member | Signature | What it does |
|---|---|---|
ready |
get ready(): boolean |
true if the state is connected. |
setHandlers |
setHandlers(handlers: HermesClientHandlers): void |
Completes the current callbacks (merged over those already set). Lets you assign handlers after the constructor. |
connect |
connect(): void |
Clears the manual-close flag and opens the connection. After open it sends hello. Returns void — readiness arrives in onReady/onState. |
disconnect |
disconnect(): void |
Marks the close as manual, cancels reconnection, closes the socket, moves the state to idle. |
Sending text, voice and utility frames¶
| Method | Signature | Returns | Frame | Errors |
|---|---|---|---|---|
sendText |
sendText(text: string, messageId?: string): string |
id of the sent message |
text |
Does not throw. Empty text is not validated — the server will reply with a bad_frame error in onError. |
sendAudio |
sendAudio(base64: string, format: 'wav' \| 'ogg', transcript?: string, messageId?: string): string |
id |
audio |
Only the compiler constrains the type; there is no runtime format check. transcript is optional. |
sendTyping |
sendTyping(): void |
— | typing |
Does not throw. |
acknowledge |
acknowledge(messageId: string): void |
— | ack |
Acknowledgement of displaying/playing the message with the given id. Does not throw. |
ping |
ping(): void |
— | ping |
Sends ts = Date.now(). The pong reply is ignored. Does not throw. |
If id is not passed, it is generated by makeId(). The adapter treats a repeat with the same id as
a duplicate and ignores it — this is the idempotency of redelivery.
No connection — not an exception, but onError
With a closed socket, all send* methods do not throw, but call
onError({ code: 'offline', message: 'Нет связи со шлюзом', fatal: false }).
Check the state via client.ready before sending.
Prompt cards¶
Sends the prompt frame. optionId is lowercased.
| Parameter | Req. | Meaning |
|---|---|---|
promptId |
yes | id from onPrompt. |
optionId |
no¹ | For exec_approval — once\|session\|always\|deny; for slash_confirm — once\|always\|cancel; for clarify — c0..cN or other. |
text |
no¹ | Free-form answer (for clarify with other). |
¹ At least one of optionId / text is required.
Throws Error if promptId is empty, or if neither optionId nor text is set.
An unknown option is replaced with a safe one
The adapter itself replaces an unrecognized value with deny (for command approval) or
cancel (for a slash command). You cannot rely on the client — send a correct value.
Media¶
| Method | Signature | Returns | Frames |
|---|---|---|---|
sendMedia |
sendMedia(kind: string, source: MediaSource, options?: MediaSendOptions): string |
id |
1..N media frames (part/parts) |
sendImage |
sendImage(source: MediaSource, options?: MediaSendOptions): string |
id |
media with kind=image |
sendDocument |
sendDocument(source: MediaSource, options?: MediaSendOptions): string |
id |
media with kind=document |
sendVoice |
sendVoice(source: MediaSource, options?: MediaSendOptions): string |
id |
media with kind=audio |
MediaSource = string | Uint8Array | { path: string }:
string— base64 only (decoded);Uint8Array— ready bytes;{ path: string }— a file path, read from disk (Node only; in the browser/RN it will throwError).
MediaSendOptions:
| Field | Type | Default | Meaning |
|---|---|---|---|
mime |
string |
by extension | MIME type of the frame. |
filename |
string |
— | File name. |
caption |
string |
— | Caption. |
messageId |
string |
makeId() |
Idempotency. |
chunkBytes |
number |
3 МБ (MEDIA_CHUNK_BYTES) |
Chunk size: the file is split into part/parts frames. |
Notes:
kindforsendMediais lowercased and must be one ofimage | video | document | audio— otherwise it throwsError. "Voice" is sent asaudio(thesendVoicewrapper).- An empty file (0 bytes) → throws
Error. - Large files are automatically split by
chunkBytes(3 MB by default): the adapter reassembles the chunks into one file, so the size does not run into the limit of a single WS frame. - If
mime/filenameare absent, the MIME is guessed by extension (guessMime), otherwiseapplication/octet-stream.
In TS a string is base64, not a path
In the TypeScript SDK, a string in source is always treated as base64. An example like
client.sendVoice('/tmp/answer.ogg') with a path string (it appeared in the README) will not
read the file, but decode the path as base64. For a file, use
client.sendVoice({ path: '/tmp/answer.ogg' }).
Callbacks HermesClientHandlers¶
All callbacks are optional; an exception inside a callback is not caught by the SDK.
| Callback | Signature | When it is called | Argument |
|---|---|---|---|
onState |
(state: ConnectionState, detail?: string) => void |
On every state change | Current state; detail — text (e.g. попытка 2) |
onReady |
(ready: ReadyPayload) => void |
The ready frame arrived (after hello) |
Session data |
onMessage |
(message: AgentMessage) => void |
A message frame without edit |
A chunk of the agent's reply |
onMessageEdit |
(message: AgentMessage) => void |
A message frame with edit: true |
An edit of a previously displayed message with the same id (streaming) |
onTyping |
() => void |
A typing frame without stop |
— (the agent is typing) |
onTypingStop |
() => void |
A typing frame with stop: true |
— (clear the indicator) |
onAvatar |
(state: AvatarState) => void |
An avatar frame |
idle \| thinking \| speaking (default idle) |
onPrompt |
(prompt: PromptPayload) => void |
A prompt frame |
A prompt card with options |
onPromptDone |
(done: PromptDonePayload) => void |
A prompt_done frame |
The card is closed (an option was chosen or it expired) |
onMedia |
(media: MediaPayload) => void |
A media frame, after all parts are assembled |
One ready file |
onError |
(error: ProtocolError) => void |
An error frame, an auth_failed rejection, a disconnect (transport), or an offline send |
Code and message |
Call order:
onStateis called before the "content" callbacks on a state change:connecting→ (connectedandonReady) on the first session; on a disconnectreconnectingwithdetail, on rejection —failedfollowed byonError(auth_failed).onMessageEditis a separate branch: aneditframe goes toonMessageEdit, not toonMessage.onMediais called once per whole file: intermediate parts are assembled internally and not exposed.
Types and constants¶
States and version¶
| Name | Type | Meaning |
|---|---|---|
PROTOCOL_VERSION |
const number |
1 |
AvatarState |
'idle' \| 'thinking' \| 'speaking' |
Avatar state |
ConnectionState |
'idle' \| 'connecting' \| 'connected' \| 'reconnecting' \| 'failed' |
Connection state |
ReadyPayload (the ready frame)¶
| Field | Type | Meaning |
|---|---|---|
v |
number |
Protocol version. |
session |
string |
Session identifier. |
device |
{ id: string; name: string } |
The device as seen by the gateway. |
avatar_url |
string |
Avatar URL for the WebView. |
capabilities |
object (see below) | Gateway capabilities. |
capabilities:
| Field | Type | Meaning |
|---|---|---|
text |
boolean |
Text support. |
voice |
boolean |
Voice support. |
typing |
boolean |
"typing" indicator support. |
avatar_states? |
AvatarState[] |
Available avatar states. |
media_in? |
string[] |
Media types accepted by the agent. |
media_out? |
string[] |
Media types from the agent. |
prompts? |
string[] |
Card types. |
edit? |
boolean |
Message edit support. |
max_media_bytes? |
number |
File limit. |
max_text_chars? |
number |
Text limit. |
New fields are optional — an old gateway does not send them, check with ?. .
AgentMessage (the message frame)¶
| Field | Type | Meaning |
|---|---|---|
id |
string |
Agent message identifier. |
text |
string |
Text of the chunk. |
index |
number |
Chunk number (from chunk.index, default 0). |
total |
number |
Total chunks (from chunk.total, default 1). |
final |
boolean |
Last chunk / end of the reply. |
edit? |
boolean |
true — an edit of an already displayed message with the same id. |
PromptOption, PromptPayload, PromptDonePayload¶
PromptOption:
| Field | Type | Meaning |
|---|---|---|
id |
string |
Option identifier. |
label |
string |
Button label. |
style? |
string |
Style hint. |
PromptPayload (the prompt frame):
| Field | Type | Meaning |
|---|---|---|
id |
string |
Card identifier. |
kind |
string |
exec_approval | slash_confirm | clarify. |
text |
string |
Question text. |
options |
PromptOption[] |
Answer options. |
session |
string |
The session the card belongs to. |
timeout_seconds |
number |
How long to wait for an answer (seconds). |
PromptDonePayload (the prompt_done frame):
| Field | Type | Meaning |
|---|---|---|
id |
string |
Card identifier. |
option_id? |
string |
The option that was applied. |
expired? |
boolean |
true — the card expired. |
MediaPayload (the media frame)¶
| Field | Type | Meaning |
|---|---|---|
id |
string |
Message identifier. |
kind |
string |
image | video | document | audio | voice | animation. |
mime |
string |
MIME type. |
filename |
string |
File name. |
caption |
string |
Caption. |
part |
number |
Part number (0-based). |
parts |
number |
Total parts. |
final |
boolean |
Last part. |
size |
number |
File size, bytes. |
data? |
string |
base64 of the whole file (all parts assembled), or absent. |
url? |
string |
A link, if the file is served by link. |
reply_to? |
string |
id of the message the media replies to. |
For display: media.data ? \data:${media.mime};base64,${media.data}` : media.url`.
Other¶
ProtocolError:
| Field | Type | Meaning |
|---|---|---|
code |
string |
Stable code (auth_failed, transport, offline, bad_frame, …). |
message |
string |
Text for the user. |
fatal |
boolean |
true — retrying is pointless. |
MEDIA_CHUNK_BYTES — const number, 3 * 1024 * 1024 (media chunk size).
Functions:
| Function | Signature | Meaning |
|---|---|---|
makeId |
(): string |
Message identifier (time in base36 + random suffix). |
bytesToBase64 |
(bytes: Uint8Array): string |
Dependency-free base64 encoding of bytes. |
base64ToBytes |
(input: string): Uint8Array |
base64 decoding (non-base64 characters are dropped, its own decoder — no need for atob/Buffer). |
Which frames arrive in which callback¶
| Server frame | Callback |
|---|---|
ready |
onReady (and onState('connected')) |
message (edit not set/false) |
onMessage |
message (edit: true) |
onMessageEdit |
typing (without stop) |
onTyping |
typing (stop: true) |
onTypingStop |
avatar |
onAvatar |
prompt |
onPrompt |
prompt_done |
onPromptDone |
media (after all parts are assembled) |
onMedia |
error |
onError |
pong |
ignored |
| unknown frame | ignored (protocol requirement) |
socket event onerror |
onError (code: 'transport', fatal: false) |
close with code 1008 |
onState('failed') + onError (code: 'auth_failed', fatal: true) |
| send with a closed socket | onError (code: 'offline', fatal: false) |
Which methods send which frames¶
| Method | Frame(s) client → server |
|---|---|
connect |
WS open + hello { t, v, token, device, user } |
disconnect |
WS close |
sendText |
text { t, id, text } |
sendAudio |
audio { t, id, format, data, text? } |
sendTyping |
typing { t } |
acknowledge |
ack { t, id } |
ping |
ping { t, ts } |
answerPrompt |
prompt { t, prompt_id, option_id?, text? } |
sendMedia / sendImage / sendDocument / sendVoice |
media { t, id, kind, mime, data, part, parts, filename?, caption? } (1..N frames) |
Examples¶
Connecting and chatting¶
import { HermesClient } from 'hermes-mobile-client';
const client = new HermesClient({
url: 'wss://agent.example.com/ws',
token: process.env.MOBILE_TOKEN!,
device: { id: 'app-1', name: 'Телефон', platform: 'android', appVersion: '1.0.0' },
handlers: {
onReady: (ready) => console.log('сессия', ready.session),
onState: (state, detail) => console.log('связь:', state, detail ?? ''),
onMessage: (m) => append(m.text, m.final), // a chunk of the reply
onMessageEdit: (m) => replace(m.id, m.text), // streaming: redraw the bubble
onAvatar: (state) => avatar.setState(state), // idle | thinking | speaking
onTyping: () => showTyping(),
onTypingStop: () => hideTyping(),
onError: (e) => console.warn(e.code, e.message, e.fatal),
},
maxBackoffMs: 15000,
});
client.connect();
client.sendText('привет, Hermes');
Command approval card¶
const client = new HermesClient({
/* … */
handlers: {
onPrompt: (prompt) => {
// prompt.options: [{ id, label, style }]
renderCard(prompt.text, prompt.options, (optionId) => {
client.answerPrompt(prompt.id, optionId); // once | session | always | deny
});
},
onPromptDone: (done) => {
closeCard(done.id, done.expired ? 'истекла' : done.option_id ?? '');
},
},
});
client.connect();
// Clarifying question: a free-form answer together with 'other'
// client.answerPrompt(prompt.id, 'other', 'мой вариант');
Sending a photo and receiving media¶
const client = new HermesClient({
/* … */
handlers: {
onMedia: (media) => {
// media.data — base64 of the whole file; media.url — if the file is served by link
const src = media.data
? `data:${media.mime};base64,${media.data}`
: media.url;
show(media.kind, src, media.filename, media.caption);
},
},
});
client.connect();
client.sendImage({ path: '/tmp/chart.png' }, { caption: 'график' }); // file (Node)
client.sendDocument(new Uint8Array(pdfBytes), { filename: 'отчёт.pdf' }); // bytes
client.sendVoice({ path: '/tmp/answer.ogg' }); // file → { path }
client.sendMedia('video', { path: '/tmp/clip.mp4' });