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.

What you're about to do
  1. Get your credentials
  2. Install the SDK
  3. Build your MCP server
  4. Deploy it
  5. Embed the widget
  6. Smoke test

01Get your credentials

Email hello@altex.ai with:

You'll get back, in the welcome email:

Important: The HMAC secret is what proves a request really came from Altex. If it leaks, anyone who has it can impersonate Altex against your MCP server. Treat it with the same care as a database password — env var or secret manager only, no commits, no client code.

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:

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."
        />
      )}
    </>
  );
}
Don't ship the widget on logout pages, signup forms, or any flow where the user isn't authenticated yet. The widget assumes a stable end-user identity — passing 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:

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:

What the failure modes look like

StatusLikely cause
401 UNAUTHORIZEDAPI 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_ALLOWEDOrigin header isn't in your tenant's allowlist. Email us to add a domain.
429 RATE_LIMITEDYou 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/listCapability filter — getPermissions didn't include the cap the tool requires for that user.
500 INTERNAL_ERROR from the agentYour 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:

  1. X-Altex-Timestamp is a Unix-seconds string within ±5 min of now.
  2. X-Altex-Signature matches the recomputed HMAC. Use a constant-time compare.
  3. 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)

NamePurpose
get_user_contextReturns the current user's profile. Called at conversation start.
get_permissionsReturns 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

FieldRequiredDefaultNotes
nameyesSurfaced in tools/list
versionyesFree-form
secretyesHMAC shared secret from welcome email
getUserContextyes(userId) => UserContext
getPermissionsyes(userId) => PermissionsResult
toolsno[]Your custom tools
portno3010Standalone server only
rateLimitno600/60s{max, windowMs} per-IP token bucket
toolTimeoutMsno30000Per-handler wall-clock cap
loggernosilent{info, warn, error}

What's next

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