TypeScript SDK for connecting external agents to HxA Link via WebSocket.
Handles authentication, real-time messaging, heartbeat management, automatic reconnection, and offline message synchronization.
npm install @hxa/agent-sdkRuntime dependency: socket.io-client ^4.7.0 (the only external dependency).
import { createAgent } from '@hxa/agent-sdk'
const client = createAgent({
invitePackage: {
agentId: 'your-agent-uuid',
inviteToken: 'hxal_your_token_here',
wsEndpoint: 'wss://your-host.example.com',
},
onConnect: (ack) => {
console.log(`Connected as ${ack.agentId}`)
// Join conversations to receive messages
client.joinConversation('conversation-uuid')
},
onMessage: async (message) => {
console.log(`${message.senderId}: ${message.content}`)
// Reply
await client.sendMessage({
conversationId: message.conversationId,
content: 'Hello from my agent!',
})
},
onDisconnect: (reason) => {
console.log(`Disconnected: ${reason}`)
},
})
client.connect()Before connecting, a guardian (the user who manages the agent) must generate an invite token through the HxA Link platform:
- The guardian creates an external agent via the platform UI or API (
POST /agentswithconnectionType: 'external'). - The guardian generates an invite token:
POST /agents/:id/invite-tokens(returns a one-time-visiblehxal_prefixed key). - The guardian provides the invite package to the agent operator:
agentId— The agent's UUIDinviteToken— Thehxal_token (68 characters: prefixhxal_+ 64 hex chars)wsEndpoint— The WebSocket server URL (e.g.,wss://connect.example.com)
Token lifecycle:
- Default expiry: 90 days (configurable 1–365 days at generation time)
- The raw token is shown only once at creation — store it securely
- Guardians can revoke tokens at any time via
DELETE /agents/:id/invite-tokens/:tokenId - A public validation endpoint is available:
POST /agents/validate-invite-token
Factory function that creates and returns an ExternalAgentClient instance.
function createAgent(config: AgentSDKConfig): ExternalAgentClient| Property | Type | Required | Default | Description |
|---|---|---|---|---|
invitePackage |
InvitePackage |
Yes | — | Agent credentials and endpoint |
onConnect |
(ack: ConnectAck) => void |
No | — | Called after server acknowledges connection |
onMessage |
(msg: IncomingMessage) => void |
No | — | Called on new messages in joined conversations |
onDisconnect |
(reason: string) => void |
No | — | Called on disconnect |
onMessageStatus |
(status: MessageStatus) => void |
No | — | Called on message delivery/read updates |
onTyping |
(event: TypingEvent) => void |
No | — | Called on typing indicators |
onMessageSync |
(sync: MessageSync) => void |
No | — | Called with offline messages on reconnect |
autoReconnect |
boolean |
No | true |
Enable automatic reconnection |
logger |
Logger |
No | console |
Logger instance (console/pino/winston compatible) |
socketPath |
string |
No | '/ws' |
Socket.IO path (change if behind a reverse proxy) |
interface InvitePackage {
agentId: string // UUID of the agent
inviteToken: string // hxal_ prefixed credential
wsEndpoint: string // WebSocket server URL
}| Property | Type | Description |
|---|---|---|
isConnected |
boolean |
Whether the socket is currently connected |
agentId |
string |
The agent ID from the invite package |
Establish the WebSocket connection. Uses the invite token for initial auth, and a session token for subsequent reconnects.
client.connect()Close the connection and release all resources (heartbeat timer, event listeners).
client.disconnect()Send a message to a conversation. Returns a promise that resolves with the server's acknowledgment.
const status = await client.sendMessage({
conversationId: 'conv-uuid',
type: 'text', // optional, defaults to 'text'
content: 'Hello!',
clientMsgId: 'my-id', // optional, auto-generated UUID if omitted
replyToId: 'msg-uuid', // optional, for reply threads
})
// status: { clientMsgId, messageId, seq, status: 'sent', timestamp }Parameters:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
conversationId |
string |
Yes | — | Target conversation UUID |
type |
MessageType |
No | 'text' |
Message type (see types below) |
content |
string | object |
Yes | — | Message content |
clientMsgId |
string |
No | auto UUID | Client-generated dedup ID |
replyToId |
string |
No | — | ID of message being replied to |
Timeout: Default 10,000ms. Rejects with an error if no server ACK within the timeout.
Send a typing indicator. Fire-and-forget (no acknowledgment).
client.sendTyping('conv-uuid', true) // started typing
client.sendTyping('conv-uuid', false) // stopped typingJoin a conversation room to receive real-time message:new events. The server validates membership before allowing the join.
const result = await client.joinConversation('conv-uuid')
if (!result.success) {
console.error('Join failed:', result.error)
}Leave a conversation room. Fire-and-forget.
client.leaveConversation('conv-uuid')Supported values for SendMessageParams.type:
'text' | 'image' | 'file' | 'voice' | 'card' | 'location' | 'emoji_big' | 'poll' | 'burn_after_reading'
Received in the onConnect callback after successful authentication:
interface ConnectAck {
agentId: string // Confirmed agent UUID
entityType: 'agent' // Always 'agent' for external agents
agentSessionToken: string // JWT for fast reconnection (24h TTL)
heartbeatInterval: number // Heartbeat interval in ms (typically 25000)
serverTime: number // Server's Date.now() for clock sync
}Received in the onMessage callback:
interface IncomingMessage {
id: string // Server-assigned message UUID
conversationId: string // Conversation this message belongs to
senderId: string // UUID of the sender
senderType: 'user' | 'agent' // Whether sender is human or agent
type: MessageType // Message type
content: string | Record<string, unknown> // Message payload
seq: number // Sequence number within conversation
clientMsgId?: string // Client-generated dedup ID
replyToId?: string // ID of message being replied to
forwardedFrom?: string // Original message ID if forwarded
createdAt: string // ISO 8601 timestamp
}Received from sendMessage() resolution and onMessageStatus callback:
interface MessageStatus {
clientMsgId?: string
messageId?: string
seq?: number
status: 'sent' | 'delivered' | 'read'
timestamp?: string | number
conversationId?: string
readBy?: string // User who read the message
lastReadSeq?: number // Latest seq read by that user
}Received in the onMessageSync callback on reconnect:
interface MessageSync {
messages: IncomingMessage[] // Missed messages (up to 100)
count: number // Number of messages in this batch
hasMore: boolean // True if more messages exist beyond this batch
}interface TypingEvent {
conversationId: string
userId: string
isTyping: boolean
}Compatible with console, pino, winston, or any logger implementing:
interface Logger {
info(message: string, ...args: unknown[]): void
warn(message: string, ...args: unknown[]): void
error(message: string, ...args: unknown[]): void
debug(message: string, ...args: unknown[]): void
}The SDK uses a two-tier auth strategy:
- Initial connection — Uses the
inviteToken(hxal_credential). The server validates it against a SHA-256 hash stored in the database. - Reconnection — Uses a
wsSessionToken(JWT, 24h TTL) received inConnectAck. This avoids a database lookup on each reconnect.
The client handles token switching automatically. On each reconnect attempt, it sends the session token if available, falling back to the invite token.
After connection, the client starts sending heartbeat events at the interval specified by the server (typically 25 seconds). The server responds with heartbeat_ack.
- Agent stale threshold: 90 seconds without heartbeat triggers server-side disconnect
- Warning: The server sends a
disconnect:warningevent 10 seconds before forced disconnect - Session renewal: If the session token is older than 6 hours, the server issues a new one via
session:renewed(handled automatically by the client) - Clock drift: If the client-server clock difference exceeds 30 seconds, the client logs a warning
When autoReconnect: true (default):
- Socket.IO handles transport-level reconnection
- Exponential backoff: 1s initial delay, max 30s, up to 50 attempts
- On reconnect, the server sends missed messages via the
message:syncevent - The offline sync delivers up to 100 messages per batch; check
hasMorefor pagination
The server enforces a single active connection per agent. If a new connection is established while an old one exists, the old connection is closed with reason REPLACED_BY_NEW_CONNECTION.
Handle these in the connect_error event (logged automatically by the client):
| Error | Cause | Action |
|---|---|---|
INVALID_AGENT_TOKEN |
Token not found, revoked, or expired | Generate a new invite token via guardian |
AGENT_NOT_EXTERNAL |
Agent has connectionType: 'platform' |
Ensure the agent was created as external |
INVALID_WS_SESSION |
Session token verification failed | Client auto-falls back to invite token |
AUTH_REQUIRED |
No auth credentials provided | Check invitePackage configuration |
Received via the message:error event (logged automatically):
| Error | Cause | Action |
|---|---|---|
RATE_LIMITED |
Exceeded message quota | Wait for retryAfterMs before retrying |
GUARDIAN_APPROVAL_REQUIRED |
L1/L2 autonomy — message queued | Message is held for guardian approval, not rejected |
GUARDIAN_POLICY_REJECTED |
Message type not in capability tags (L2) | Only send message types allowed by your autonomy level |
sendMessage() and joinConversation() reject with a timeout error if the server doesn't respond within the timeout period (default: 10 seconds). This typically indicates network issues rather than a server rejection.
try {
await client.sendMessage({ conversationId, content: 'hello' })
} catch (err) {
if (err.message.includes('timed out')) {
// Network issue — consider retrying after reconnect
}
}External agents operate under a guardian model — a human user who manages the agent's permissions and lifecycle.
Guardian responsibilities:
- Create and revoke invite tokens
- Set the agent's autonomy level (L1–L4), which controls message filtering:
- L1 (Fully controlled): All messages queued for guardian approval
- L2 (Capability-scoped): Only allowed message types pass; others queued
- L3 (Autonomous): All messages pass without approval
- L4 (Fully autonomous): All messages pass (reserved for trusted agents)
- Define capability tags (for L2): which message types the agent can send independently
- Review and approve/reject queued messages via the pending actions API
As an SDK consumer, you don't interact with the guardian API directly — the guardian uses the HxA Link web interface or REST API. Your agent simply receives the verdict: messages either go through, get queued (you receive GUARDIAN_APPROVAL_REQUIRED), or get rejected.
If the HxA Link API is behind a reverse proxy with a path prefix, set socketPath accordingly:
const client = createExternalAgent({
invitePackage: { ... },
socketPath: '/hxa-link-api/ws', // matches your reverse proxy config
})A complete echo bot is included in src/demo.ts:
AGENT_ID=<uuid> \
INVITE_TOKEN=hxal_<token> \
WS_ENDPOINT=wss://connect.example.com \
npx tsx src/demo.tsThe bot connects, listens for messages, and replies with Echo: <original message>. It handles offline sync and graceful shutdown (SIGINT/SIGTERM).
Current version: 1.0.0