@hawkenai/atrium-sdk
v0.3.0
Published
Browser AtriumClient, server AtriumAdmin, and verifyAtriumMcpJwt for partner MCP auth. Mint tokens on your server; the browser client only holds a short-lived user session.
Downloads
735
Readme
Atrium client SDK
Headless Atrium API client for partners who already have their own users and UI.
The browser never holds the org secret. Your server mints a short-lived per-user token. This client attaches it to every Atrium call and refreshes it.
npm install @hawkenai/atrium-sdkPartner setup
- In Atrium: Settings → Developer. Generate the org API key (
hawko_). Keep it on your server only. - Add your app origins (scheme + host + port). Example:
https://app.partner.com. - For partner tools, set the MCP URL and a separate MCP signing secret on the same page.
- Add one endpoint behind your login:
GET /api/atrium-token
→ { "token": "<session>", "expiresIn": 3600 }That endpoint calls Hawken:
POST /api/atrium/partner/sessions
Authorization: Bearer <hawko_ org key>
Content-Type: application/json
{
"externalUserId": "<your user id>",
"name": "Ada Lovelace",
"email": "[email protected]"
}If that email already belongs to a user in this org, that user is reused and linked. 409 EMAIL_ALREADY_EXISTS only when the email belongs to another organization.
- In the browser:
import { AtriumClient } from '@hawkenai/atrium-sdk'
const atrium = new AtriumClient({
tokenUrl: '/api/atrium-token',
})apiBase is optional and defaults to https://platform.hawken.ai. Pass it for local or staging (http://127.0.0.1:3001). tokenUrl is called with credentials: 'include' so your session cookie is sent. Use getToken instead if you mint another way.
Do not pass the org key (hawko_), a hawk_ API key, or a legacy atk_ secret into AtriumClient. Those stay on your server. Use AtriumAdmin there.
Admin (server only)
AtriumAdmin holds the org API key (hawko_) and calls the same /api/atrium/* URLs. Legacy atk_ keys still work. It can provision teams, members, and projects without being the team owner. Chat, conversations, files, and personal settings still require a minted user session.
import { AtriumAdmin, AtriumClient } from '@hawkenai/atrium-sdk'
const admin = new AtriumAdmin({ secret: process.env.ATRIUM_ORG_SECRET })
const { user, token } = await admin.sessions.create({
externalUserId,
name,
email,
})
await admin.teams.addMember(teamId, { userId: user.id })Creating a team or project with the org key requires ownerUserId (the Hawken user id from mint).
| Method | HTTP | Returns |
|---|---|---|
| sessions.create(body) | POST /api/atrium/partner/sessions | { token, expiresIn, user } |
| orgMembers.list() | GET /api/atrium/org-members | { members } |
| teams.list() | GET /api/atrium/teams | { teams } (all org teams) |
| teams.get(id) | GET /api/atrium/teams/:id | { team } |
| teams.create(body) | POST /api/atrium/teams | { team } |
| teams.update(id, body) | PATCH /api/atrium/teams/:id | { team } |
| teams.members.list(id) / teams.addMember / teams.removeMember | members CRUD | { members } / { member, team } / { team } |
| teams.settings.get(id) / teams.settings.update(id, body) | team settings | { team, settings, canEdit? } |
| projects.list() | GET /api/atrium/projects | { projects } (all non-hidden) |
| projects.get(id) | GET /api/atrium/projects/:id | { project } |
| projects.create(body) | POST /api/atrium/projects | { project } |
| projects.update(id, body) | PATCH /api/atrium/projects/:id | { project } |
Allowed origins
If the org allowlist is empty, any browser origin may call Atrium (the minted token is still required).
If the allowlist is set, browser calls must send an Origin that is on the list. The Hawken web app is always allowed. Server-side calls with no Origin header are allowed.
Add both http://127.0.0.1:<port> and http://localhost:<port> in development. Browsers treat those as different origins.
Client methods
Request and response shapes live in atrium-client.d.ts (TypeScript picks them up from the package). Conversation title is optional; the backend names the chat from the first user turn.
const { conversation } = await atrium.conversations.create()
const stream = atrium.chat.stream({
model: 'openai/gpt-4o-mini',
conversationId: conversation.id,
messages: [{ role: 'user', content: 'Hello' }],
})Nested names and flat names call the same methods.
| Method | HTTP | Returns |
|---|---|---|
| conversations.list() / listConversations() | GET /api/atrium/conversations | { conversations } |
| conversations.create(body?) / createConversation(body?) | POST /api/atrium/conversations | { conversation } |
| conversations.messages.list(id) / listMessages(id) | GET /api/atrium/conversations/:id/messages | { messages } |
| conversations.messages.create(id, body) / appendChatTurn(id, body) | POST /api/atrium/conversations/:id/messages | { messages } |
| chat.stream(body, options?) / streamChat(body, options?) | POST /api/atrium/chat/stream | async iterator of SSE frames (delta is token text) |
| getLlmSpecs() | GET /api/atrium/llm-specs | { results } |
| teams.list() / listTeams() | GET /api/atrium/teams | { teams } |
| teams.create(body) / createTeam(body) | POST /api/atrium/teams | { team } |
| teams.update(id, body) / updateTeam(id, body) | PATCH /api/atrium/teams/:id | { team } |
| projects.list() / listProjects() | GET /api/atrium/projects | { projects } |
| projects.create(body) / createProject(body) | POST /api/atrium/projects | { project } |
| projects.update(id, body) / updateProject(id, body) | PATCH /api/atrium/projects/:id | { project } |
| files.list() / listLibraryFiles() | GET /api/atrium/library/files | { files } |
| files.upload(files) / uploadLibraryFiles(files) | POST /api/atrium/library/files | { files } |
| files.download(id) / downloadFile(id) | GET /api/atrium/files/:id/download | Blob |
Also on the client: ensureToken(), request(), requestJson(). Those are for advanced use. request only allows /api/atrium/*.
Partner tools (MCP)
These instructions are for you (the partner), not Hawken internals.
Atrium runs the agent loop. For tools that hit your data or APIs, host an MCP server on your Node process at /api/atrium-mcp/*. Atrium calls that URL during the turn. Do not send tools on chat.stream today — Atrium overwrites that field with its artifact tools.
Use MCP for server work (get_invoice, search_matters). Keep UI actions (open_invoice, pick a row) in your app, not on MCP.
When Atrium hits your MCP
Atrium does not call your tools on every token or every chat message.
| When | What Atrium does |
|---|---|
| Turn start (chat.stream) | tools/list once (discover schemas). Cache this per org / URL / tool version. Not an index crawl. |
| Model chooses a partner tool | tools/call for that tool only |
| Later turns in the same chat | Reuse cached defs unless you change tools (then we list again) |
Unused tool definitions still go to the model each turn you enable MCP. Unused tools are not executed. Keep the list small.
Host it on the same server
Add Streamable HTTP next to your existing /api/atrium-token route. Same deploy. Atrium (Hawken’s servers) must be able to reach it — localhost only works on your machine.
npm install @modelcontextprotocol/sdk zodimport express from 'express'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { z } from 'zod'
const app = express()
app.use(express.json())
function createPartnerMcpServer(user: { id: string }) {
const server = new McpServer({ name: 'partner-atrium-tools', version: '1.0.0' })
server.tool(
'get_invoice',
'Load one invoice the current user may see.',
{ invoiceId: z.string() },
async ({ invoiceId }) => {
const invoice = await loadInvoiceForUser(user.id, invoiceId)
return {
content: [{ type: 'text', text: JSON.stringify(invoice) }],
}
}
)
return server
}
app.all('/api/atrium-mcp', async (req, res) => {
const user = await requirePartnerUserFromBearer(req.headers.authorization)
if (!user) {
res.status(401).json({
jsonrpc: '2.0',
id: null,
error: { code: -32001, message: 'Unauthorized' },
})
return
}
const server = createPartnerMcpServer(user)
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
})
await server.connect(transport)
await transport.handleRequest(req, res, req.body)
})sessionIdGenerator: undefined is stateless (one request, no in-memory session). Prefer that behind a load balancer. Pass req.body if Express already parsed JSON.
Package names in the MCP TypeScript SDK move. If those imports fail, use the current Streamable HTTP + Express example from the MCP TypeScript SDK.
Auth and identity
Atrium is the HTTP client (the agent runs on Hawken’s servers, not in the partner browser). It does not send hawko_, a hawk_ key, the user’s browser cookie, or the minted Atrium user session (that token is only for the browser/SDK calling Atrium).
Atrium sends a short-lived JWT it signed:
POST /api/atrium-mcp
Authorization: Bearer <atrium-mcp-jwt>
Content-Type: application/jsonIntended claims (same externalUserId you sent at mint):
{
"iss": "https://platform.hawken.ai",
"aud": "https://app.partner.com/api/atrium-mcp",
"exp": 1710003600,
"orgId": "<hawken org id>",
"userId": "<hawken user id>",
"externalUserId": "<your user id>"
}TTL is minutes, not hours. One JWT per MCP request (list or call).
How you verify: store an MCP signing secret in Atrium Settings → Developer (separate from hawko_). Atrium HS256-signs the JWT with that secret. Verify with verifyAtriumMcpJwt from @hawkenai/atrium-sdk/mcp — not on AtriumAdmin. Then load your user from externalUserId. Reject if the secret is missing or the JWT fails.
import { verifyAtriumMcpJwt } from '@hawkenai/atrium-sdk/mcp'
function requirePartnerUserFromBearer(authorization: string | undefined) {
const claims = verifyAtriumMcpJwt(authorization, {
secret: process.env.ATRIUM_MCP_SIGNING_SECRET!,
audience: 'https://app.partner.com/api/atrium-mcp',
})
return loadUserByExternalId(claims.externalUserId)
}Do not use hawko_ as that HMAC. If it leaks, an attacker can mint users and call your tools.
Run every tool as that user. Do not use a god-mode DB key. HTTPS in production.
What to register
- A small tool list. Unused defs cost tokens every turn.
- Stable names. Do not collide with Atrium names:
create_artifact,update_artifact,read_artifact,list_library,run_code. - Fast, idempotent handlers. Atrium may retry if a stream drops.
- Public base URL, e.g.
https://app.partner.com/api/atrium-mcp. Save that URL and the signing secret in Atrium Settings → Developer.
If the model always needs the same small blob, load it on your server and put it in messages before chat.stream. That is context, not tools.
Local demo
From atrium-sdk-partner-demo:
./scripts/start.shApp: http://127.0.0.1:3040 (or http://localhost:3040). Tests: node scripts/test.mjs.
License
MIT. See LICENSE. Using Atrium still requires a Hawken account and an Atrium license.
