Tenant onboarding
Plan for 30 minutes. You will need: a Node-capable deploy target for your MCP server
(Vercel / Cloud Run / Fly / Railway all work), and a site you can add a
<script> tag to.
01Get your credentials
Email hello@altex.ai with:
- The domain you'll embed the widget on (e.g.
app.acme.com) — also includehttp://localhost:3000(or whichever port your dev server uses) so widget calls work during local development - One-sentence description of what your product does
- Whether your users are authenticated (Supabase / Auth0 / cookie / other) or anonymous visitors
- Which LLM provider you want — Anthropic or OpenAI
You'll get back, in the welcome email:
- An API key — format
ak_<tenant-id>_<48-hex>. Public-by-design (it ships in client HTML), scoped by CORS to your declared domain. - An HMAC secret — 64-hex string. Server-side only. Never ship in client code.
- Your tenant ID — short slug used in URLs and audit logs.
02Install the SDK
The SDK is @altex.ai/sdk. Subpath exports — install once, import only what you need:
npm install @altex.ai/sdk
Published on the public npm registry — npmjs.com/package/@altex.ai/sdk.
Then:
import { createAltexMCPServer } from "@altex.ai/sdk/mcp"; // future subpaths: @altex.ai/sdk/webhooks, @altex.ai/sdk/admin
The SDK requires Node 20+.
03Build your MCP server
Your MCP server is the bridge between Altex's agent and your product's data. Altex calls your tools at runtime when a chat request needs real information from your system.
The minimum config
import { createAltexMCPServer } from "@altex.ai/sdk/mcp"; const server = createAltexMCPServer({ name: "acme-mcp", version: "0.1.0", // HMAC secret from your welcome email. Server-side env var only. secret: process.env.ALTEX_MCP_SECRET!, // Required: who is the current user? getUserContext: async (userId) => ({ userId, name: "Alice", role: "admin", plan: "Pro", }), // Required: what can they do? getPermissions: async (userId) => ({ capabilities: ["read:projects", "write:items"], permissions: [ { action: "read", resource: "projects" }, { action: "write", resource: "items" }, ], }), tools: [ { name: "list_projects", description: "List the user's projects.", capabilities: ["read:projects"], parameters: { type: "object", properties: { limit: { type: "number", description: "Max items (default 50)" }, }, }, handler: async (args, userContext) => { // userContext.userId is the END USER, not your tenant. return { projects: await db.projects.findByOwner(userContext.userId) }; }, }, ], }); await server.start();
What the SDK does for you
Out of the box, before any tool runs:
- HMAC verification. Rejects 401 if the request didn't come from Altex.
- Per-IP rate limit. 600 req/min default. Override via
rateLimit. - Body cap. 1 MB. Returns 413.
- Per-tool timeout. 30 s default. Override via
toolTimeoutMs. - JSON-Schema validation against each tool's
parameters. Bad args never reach your handler. - Sanitized errors. Handler exceptions surface as
"Tool 'X' failed"to the agent. Your internals stay in your logs. - Capability filtering. Tools whose
capabilitiesaren't ingetPermissions().capabilitiesfor the current user are removed beforetools/list— the model can't see them or call them. /healthzfor load-balancer probes. Unauthenticated.{status: "ok"}.
Capability gating (RBAC)
Tag tools with capabilities: string[]. Return the user's
capabilities from getPermissions(userId). The SDK only registers tools
whose tags are a subset of the user's set. Strings are tenant-defined — Altex never inspects
their meaning.
{
name: "fill_form",
capabilities: ["forms"], // only Pro/Corp users get this
parameters: { ... },
handler: ...
}
Map your subscription tiers, roles, or feature flags to capability strings in
getPermissions. Trial users return ["chat"];
Pro users return ["chat", "forms"]; Corp users add
"knowledge". Whatever model fits your product.
For per-call decisions ("user can read projects but only their own"), add an
authorize hook on the tool:
{
name: "describe_project",
capabilities: ["read:projects"],
authorize: async (args, userContext) => {
const p = await db.projects.findOne(args.projectId);
if (p?.ownerId !== userContext.userId) {
return { allow: false, reason: "Not the project owner" };
}
return { allow: true };
},
handler: async (args) => ...
}
The authorize hook is also where you plug in
Cerbos,
OPA, or
Auth0 FGA
if you have one.
04Deploy your MCP server
The SDK ships a Node HTTP server, plus exports a handleRequest(req, res)
function for embedding inside framework routes. Pick the pattern that matches where
your product already lives.
Next.js App Router (recommended)
Drop a route at app/api/mcp/route.ts using
createAltexMCPRequestHandler — the same helper works on
Bun, Deno, Cloudflare Workers (Node compat), and Hono.
// app/api/mcp/route.ts import { createAltexMCPRequestHandler } from "@altex.ai/sdk/mcp"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const handler = createAltexMCPRequestHandler({ /* same config as Step 3 */ }); export const POST = handler; export const GET = handler; // only matches /healthz under this path
For a Cloud Run / Fly probe at /api/mcp/healthz, add:
// app/api/mcp/healthz/route.ts export { GET } from "../route";
Next.js Pages Router
For projects still on Pages Router, use the Node-style
handleRequest directly:
// pages/api/mcp.ts import type { NextApiRequest, NextApiResponse } from "next"; import { createAltexMCPServer, type AltexMCPServer } from "@altex.ai/sdk/mcp"; // Disable Next's body parser — the SDK reads raw bytes for HMAC. export const config = { api: { bodyParser: false } }; let cached: AltexMCPServer | null = null; function getServer() { if (!cached) cached = createAltexMCPServer({ /* config */ }); return cached; } export default async function handler(req: NextApiRequest, res: NextApiResponse) { await getServer().handleRequest(req as any, res as any); }
Cloud Run / Fly / Railway / standalone Node
The SDK runs its own HTTP server on the configured port. Wrap it in any container.
/healthz is built in for liveness probes.
// server.ts import { createAltexMCPServer } from "@altex.ai/sdk/mcp"; const server = createAltexMCPServer({ port: parseInt(process.env.PORT ?? "8080", 10), // ... rest of config }); await server.start();
Express / Fastify / Hono / any Node framework
import { createAltexMCPServer } from "@altex.ai/sdk/mcp"; const mcp = createAltexMCPServer({ /* config */ }); app.post("/api/mcp", async (req, res) => { await mcp.handleRequest(req, res); }); app.get("/api/mcp/healthz", async (req, res) => { req.url = "/healthz"; await mcp.handleRequest(req, res); });
Once deployed, send Altex your MCP URL via the welcome-email reply:
https://your-app.example.com/api/mcp. We'll wire it into
your tenant config and confirm.
05Embed the widget
The widget is a single <script> tag. Two cases:
Anonymous visitors (public marketing site, no login)
<script src="https://altex.ai/widget.js" data-api-url="https://api.altex.ai" data-api-key="ak_acme_..." data-title="Ask Acme" data-greeting="Hi! How can I help?"></script>
Authenticated users
Pass the user's identity in data-end-user-id. Altex signs that into
the HMAC envelope; your getUserContext(userId) and
getPermissions(userId) receive it; your tool handlers receive it
via userContext.userId. Use whichever ID is canonical in your auth
system (Supabase user.id, Auth0 sub,
your own profiles.id).
Server-render the script tag once you know the user. In Next.js App Router:
// app/(dashboard)/layout.tsx import Script from "next/script"; import { createClient } from "@/lib/supabase/server"; export default async function DashboardLayout({ children }) { const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); return ( <> {children} {user && ( <Script src="https://altex.ai/widget.js" strategy="afterInteractive" data-api-url="https://api.altex.ai" data-api-key="ak_acme_..." data-end-user-id={user.id} data-title="Ask Acme" data-greeting="Hi! Ask me about your account." /> )} </> ); }
undefined as data-end-user-id falls
back to anonymous, which gets the empty capability set from your
getPermissions("anonymous") implementation.
06Smoke test
Two layers to verify, in order: first the MCP server (your code) responds to a signed request, then Altex's chat endpoint (which calls your MCP and streams the response).
Layer 1: signed MCP tools/list
Hit your deployed MCP URL directly with a real HMAC. The onboarding CLI's output includes this snippet pre-filled with your secret; reproduce it with:
export ALTEX_MCP_SECRET='<hmac-secret-from-welcome-email>' export ALTEX_END_USER_ID='smoke-test' TS=$(date +%s) BODY='{"jsonrpc":"2.0","id":1,"method":"tools/list"}' SIG=$(printf '%s\n%s\n%s' "$TS" "$ALTEX_END_USER_ID" "$BODY" \ | openssl dgst -sha256 -hmac "$ALTEX_MCP_SECRET" | awk '{print $2}') curl -sS -X POST https://your-app.com/api/mcp \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -H "x-altex-timestamp: $TS" \ -H "x-altex-end-user-id: $ALTEX_END_USER_ID" \ -H "x-altex-signature: $SIG" \ -d "$BODY"
What the responses tell you:
- JSON-RPC envelope listing tools — SDK is wired up correctly.
- 401 UNAUTHORIZED — HMAC mismatch. Verify
ALTEX_MCP_SECRETreached your deploy environment without quoting/whitespace damage. - 400
Mcp-Session-Idrequired — your transport is in stateful mode. PasssessionIdGenerator: undefinedwhen you build the transport (the SDK does this for you, so this only fires if you've replaced it). - Connection refused / 404 — your deploy isn't live at the URL you onboarded with. Email Altex with the corrected URL.
Layer 2: Altex chat (streamed SSE)
Once Layer 1 passes, hit Altex's chat endpoint. Responses stream back as
Server-Sent Events; use curl --no-buffer to see them
arrive in real time:
curl --no-buffer -X POST https://api.altex.ai/api/v1/chat \ -H "Authorization: Bearer ak_acme_..." \ -H "Origin: https://app.acme.com" \ -H "content-type: application/json" \ -H "accept: text/event-stream" \ -d '{ "message": "what can you help me with?", "endUserId": "<a-real-user-uuid>", "conversationId": "smoke-test" }'
You'll see data: {"type":"text-delta","delta":"..."}
events arriving as the model generates, then
data: {"type":"tool-call","toolName":"list_projects",...}
when a tool fires, then a final data: [DONE].
Frontend integrators should consume this with the browser
EventSource API or the
fetch + ReadableStream pattern.
What you should see across both layers:
- 200 OK on the chat call, streaming the model response.
- One HMAC-verified request landing in your MCP server logs per tool call.
- The tool fire shows up in
altex_tool_executionsvia the analytics API.
What the failure modes look like
| Status | Likely cause |
|---|---|
| 401 UNAUTHORIZED | API key wrong, or your MCP server's ALTEX_MCP_SECRET doesn't match what Altex has on file. Verify the secret reached your deploy environment. |
| 403 ORIGIN_NOT_ALLOWED | Origin header isn't in your tenant's allowlist. Email us to add a domain. |
| 429 RATE_LIMITED | You exceeded the 60/min per-API-key chat rate limit. Backs off automatically. |
| Tool returns "Not authorized" | An authorize hook denied. Check the resource ownership check in your tool config. |
Tool not in tools/list | Capability filter — getPermissions didn't include the cap the tool requires for that user. |
| 500 INTERNAL_ERROR from the agent | Your MCP server returned an error or timed out. Check your server's structured logs (the SDK's logger hook). |
Reference
HMAC signing format (for non-Node MCP servers)
If you're implementing the MCP server in Go / Python / Rust / etc, you'll need to verify Altex's signature manually. The format is:
signature = HMAC-SHA256(
key = ALTEX_MCP_SECRET,
data = "<X-Altex-Timestamp>\n<X-Altex-End-User-Id>\n<raw request body>"
).hexdigest()
Verify:
X-Altex-Timestampis a Unix-seconds string within ±5 min of now.X-Altex-Signaturematches the recomputed HMAC. Use a constant-time compare.- The end-user header is signed alongside the body, so a proxy can't substitute a different user id.
If you're on the Node SDK, you don't have to think about this — the SDK does it. Reach out if you need a verified reference implementation in another language.
Required tools (auto-registered by the SDK)
| Name | Purpose |
|---|---|
get_user_context | Returns the current user's profile. Called at conversation start. |
get_permissions | Returns the user's capabilities. Drives the capability filter. |
You implement the callbacks; the SDK registers and enforces them. They run before any of your custom tools.
Configuration reference
| Field | Required | Default | Notes |
|---|---|---|---|
name | yes | — | Surfaced in tools/list |
version | yes | — | Free-form |
secret | yes | — | HMAC shared secret from welcome email |
getUserContext | yes | — | (userId) => UserContext |
getPermissions | yes | — | (userId) => PermissionsResult |
tools | no | [] | Your custom tools |
port | no | 3010 | Standalone server only |
rateLimit | no | 600/60s | {max, windowMs} per-IP token bucket |
toolTimeoutMs | no | 30000 | Per-handler wall-clock cap |
logger | no | silent | {info, warn, error} |
What's next
- Write actions. Tools that mutate state (create_project, generate_invoice). Coming with explicit user confirmation flow — track on the platform changelog.
- Per-tool metering. Bill your own users by capability bundle. The platform records every tool call to
altex_tool_executions; query bytool_nameand join against your tenant-side capability map. A denormalizedcapabilitycolumn is on the roadmap. - Webhook events. Subscribe to platform events (chat.created, tool.failed, usage.threshold). Coming with HMAC-signed delivery + retry/backoff.
Stuck?
Email hello@altex.ai. We're onboarding the first 10 teams by hand and pair on integrations directly.
Start onboarding →© Altex. hello@altex.ai