@openhex-ai/agent-sdk
v0.3.0
Published
Build AI agents on the Openhex platform. Programmable agent loop, tools, hooks, sessions, MCP, and an embeddable React chat UI — the same runtime that powers Openhex agents, as a library.
Maintainers
Readme
@openhex-ai/agent-sdk
Build AI agents on the Openhex platform. The same agent loop, built-in tools, hooks, sessions, and MCP support that power Openhex agents — exposed as a programmable TypeScript library.
Status. The chat API, the React chat UI (
/react), and the workspace API are implemented — all speak the exact protocol the platform exposes (the user-side apps' conversations protocol and the published/api/v2/workspaces/*surface). The localquery()agent loop is still a scaffold (throwsNotImplementedError) and lands in a follow-up MR.
Install
npm install @openhex-ai/agent-sdk
# or
pnpm add @openhex-ai/agent-sdkAuthentication
Get an API key (mysta_…) from the Openhex console, then set it as an
environment variable (or pass { apiKey } to the client):
export OPENHEX_API_KEY=mysta_...Chat with an agent
The chat API mirrors the user-side apps' conversations protocol: a message creates (or continues) a conversation and routes it to the agent (auto-provisioning the agent's pod on first contact); the reply streams back over SSE until the turn completes.
import { OpenhexClient } from '@openhex-ai/agent-sdk';
const client = new OpenhexClient({ agentId: '…' /* apiKey from env */ });
// Simplest: send and await the aggregated turn (creates a conversation).
const turn = await client.sendMessage('Summarize the latest support tickets');
console.log(turn.text);
console.log(turn.toolCalls); // tools the agent used
console.log(turn.conversationId); // reuse to continue the chatMulti-turn conversations
A Conversation remembers its id so follow-ups keep context:
const convo = client.conversation();
await convo.send('Remember my project is called Orbit.');
const turn = await convo.send('What is my project called?'); // → "Orbit"Streaming
import { extractText, isTurnComplete } from '@openhex-ai/agent-sdk';
for await (const record of client.runTurn('Group them by theme')) {
if (record.sender === 'assistant') process.stdout.write(extractText(record));
if (isTurnComplete(record)) break;
}Low-level protocol
client.chat exposes the wire 1:1 when you need full control:
// POST /conversations/send — create (targetAgentIds) or continue (conversationId)
const { conversationId, userEventId } = await client.chat.send({
message: 'hello',
targetAgentIds: [agentId],
});
// GET /conversations/:id/stream — resumable (auto-reconnects with backoff).
// Resume from a record's `id`, or from `userEventId` to capture just the reply.
for await (const record of client.chat.stream(conversationId, { lastEventId: userEventId })) {
console.log(record.sender, record.raw.type);
if (record.raw.type === 'result') break;
}
await client.chat.interrupt(conversationId); // POST /conversations/:id/interrupt
const { entries } = await client.chat.messages(conversationId); // full historyEach stream record is { id, seq, sender, event, timestamp, sessionId, raw }
where raw carries the agent-runtime event ({ type: 'assistant', message: { content: […] } },
{ type: 'result' }, etc.) — identical to what the app's chat UI consumes.
React chat UI (/react)
Drop a chat box onto any page and it talks to your agent over the same
protocol. The React layer is a separate entry point so the core SDK stays
framework-free; react / react-dom are peer dependencies and the styles are
self-contained (no CSS import, no build step).
npm install @openhex-ai/agent-sdk react react-domTurnkey floating widget
import { ChatWidget } from '@openhex-ai/agent-sdk/react';
export function App() {
return (
<ChatWidget
agentId="ce9382ad-…"
token={sessionToken} // a member session token from YOUR backend
title="HYROX 备赛教练"
greeting="嗨!我是你的 HYROX 备赛教练,问我任何训练问题吧。"
accentColor="#0f766e"
theme="auto" // light | dark | auto
/>
);
}That renders a launcher bubble (bottom-right) that opens a panel — a corner
panel on desktop, full-screen on mobile. Use <ChatBox> instead to embed the
panel inline in your own layout (it fills its parent).
Browser-safe auth — never ship an API key
A mysta_… API key is a secret; it must never reach the browser. Mint a
short-lived member session token on your backend and hand it to the widget:
// —— your backend (Node) ——
import { OpenhexClient } from '@openhex-ai/agent-sdk';
const openhex = new OpenhexClient({ apiKey: process.env.OPENHEX_API_KEY }); // sk_ws_… or owner JWT
app.post('/chat-token', async (req, res) => {
const { token } = await openhex.workspace('acme').mintSession({
sp_user_ref: req.user.id,
ttl_seconds: 3600,
});
res.json({ token });
});// —— your frontend ——
<ChatWidget
agentId="…"
getToken={async () => (await fetch('/chat-token', { method: 'POST' })).json().then(r => r.token)}
/>getToken is called before every request, so an expiring token refreshes
transparently. Pass a static token if you mint one per page load, or a fully
built client (an OpenhexClient) for total control.
Headless hook — bring your own UI
useOpenhexChat owns the conversation state (messages, streaming, interrupt,
retry) so you can render whatever you like:
import { useOpenhexChat } from '@openhex-ai/agent-sdk/react';
function MyChat() {
const { messages, send, isResponding, interrupt } = useOpenhexChat({
agentId: '…',
getToken,
});
// …render `messages`, call `send(text)` / `interrupt()`
}| Prop | Purpose |
| ----------------------------------------------- | ---------------------------------------------------------- |
| agentId | agent to route a new conversation to |
| conversationId | resume an existing conversation (loads history) |
| token / getToken | member session token (static or refreshing) |
| client | a pre-built OpenhexClient / AgentChatClient (advanced) |
| theme / accentColor | one-prop theming (light | dark | auto) |
| greeting, title, avatarUrl, placeholder | presentation |
| injectStyles={false} | opt out of the bundled CSS and style it yourself |
Workspaces
A workspace is a billing + membership boundary you (a service provider /
partner) own. client.workspaces wraps the published /api/v2/workspaces/*
API 1:1 — provision members, mint member sessions, grant credits, and read
reporting. It speaks the same contract documented at
/api/v2/workspaces/openapi.json.
Auth is the Bearer token the client is configured with:
| Token | Unlocks |
| ----------------------------- | ----------------------------------------------------------------------------------------- |
| Workspace API key (sk_ws_…) | whoami, member provision/suspend, sessions, member list, credit grants |
| Owner user JWT | the above plus reporting (ledger, insights, member ledger) and API-key management |
| none | the public SMS-signup endpoints (sendSmsCode / verifySmsCode) |
import { OpenhexClient } from '@openhex-ai/agent-sdk';
// A partner backend authenticates with its workspace API key.
const client = new OpenhexClient({ apiKey: 'sk_ws_…' });
// Confirm which workspace the key belongs to.
const me = await client.workspaces.whoami();
// A slug-bound handle drops the slug from every call.
const ws = client.workspace(me.slug);
// Provision a member (idempotent on your sp_user_ref), grant credits,
// then mint a session JWT for the member's client.
const member = await ws.provisionMember({ sp_user_ref: 'user-42', display_name: 'Ada' });
await ws.grantCredits(member.member_id, {
amount: 500,
idempotency_key: `bonus:${member.member_id}`,
});
const session = await ws.mintSession({ sp_user_ref: 'user-42', ttl_seconds: 3600 });Public SMS signup (no auth — requires public_sms_signup_enabled on the workspace):
const pub = new OpenhexClient({ apiKey: 'unused' }); // token ignored on these routes
await pub.workspaces.sendSmsCode('acme', { phone: '13800000000' });
const session = await pub.workspaces.verifySmsCode('acme', {
phone: '13800000000',
code: '123456',
});The admin-side routes (workspace ledger, insights, per-member ledger, API-key management, and the 4 workspace-lifecycle CRUD endpoints) are intentionally not part of this client. They power the SP admin webapp and aren't in the published partner spec — they'd 401 with a workspace API key anyway.
Local agent loop (scaffold)
import { query } from '@openhex-ai/agent-sdk';
for await (const message of query({
prompt: 'Summarize the latest support tickets',
options: { allowedTools: ['Read', 'WebSearch'] },
})) {
if (message.type === 'result') console.log(message.result);
}Custom tools
import { query } from '@openhex-ai/agent-sdk';
import { tool, createSdkMcpServer } from '@openhex-ai/agent-sdk/tools';
import { z } from 'zod';
const getWeather = tool(
'get_weather',
'Look up the weather for a city',
z.object({ city: z.string() }),
async ({ city }) => ({ content: [{ type: 'text', text: `Sunny in ${city}` }] })
);
const q = query({
prompt: "What's the weather in Tokyo?",
options: {
mcpServers: { local: createSdkMcpServer({ name: 'local', tools: [getWeather] }) },
},
});Concepts
| Concept | Where |
| --------------------------- | ----------------------------------------------------------- |
| Chat with a published agent | client.sendMessage(), client.runTurn(), client.chat.* |
| Embeddable chat UI | <ChatWidget>, <ChatBox>, useOpenhexChat() (/react) |
| Resumable record stream | client.chat.stream({ lastEventId }) |
| One-shot / streaming runs | query() (scaffold) |
| Stateful client + sessions | OpenhexClient |
| Built-in & custom tools | options.allowedTools, tool(), createSdkMcpServer() |
| Lifecycle hooks | options.hooks, hookMatcher() |
| Subagents | options.agents |
| External integrations (MCP) | options.mcpServers |
| Permissions | options.permissionMode, options.canUseTool |
The surface intentionally mirrors the Claude Agent SDK so the mental model transfers directly.
Development
pnpm --filter @openhex-ai/agent-sdk build # bundle to dist/ via tsup
pnpm --filter @openhex-ai/agent-sdk typecheck # tsc --noEmit
pnpm --filter @openhex-ai/agent-sdk test # jestLicense
See LICENSE.
