Embed a UI

Pre-1.0. @m8tes/react is the React UI layer for the API. Install with npm i @m8tes/react (latest tracks the newest release). Component and hook APIs may change before 1.0; the streaming wire protocol (m8tes.stream.v2) stays semver-stable. Prefer the Python SDK when you need full API coverage.

Put a live agent in your React app: streaming replies, tool calls, and inline approvals, themed to your product. Two pieces: the component, and a one-file server route so the secret key never reaches the browser.

TSX
"use client";
import { M8tesProvider, MateChat } from "@m8tes/react";
import "@m8tes/react/styles.css";

export function AgentPanel() {
  return (
    <M8tesProvider>
      <MateChat />
    </M8tesProvider>
  );
}

Install

Terminal
npm i @m8tes/react

The server-only handler lives behind a separate @m8tes/react/server import, so the secret can never leak into a client bundle.

Prefer to own the source? The shadcn registry copies the component into your repo:

Terminal
npx shadcn@latest add https://www.m8tes.ai/r/mate-chat.json

Add the server route (your key stays server-side)

There's no browser-safe key, so a route on your server holds the m8_ secret and forwards stream requests. In Next.js (App Router), one file does it:

TSX
// app/api/m8tes/[...path]/route.ts
import { createM8tesHandler } from "@m8tes/react/server";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 300; // long-running streams

export const { GET, POST } = createM8tesHandler({
  apiKey: process.env.M8TES_API_KEY!,        // secret, server-side only
  resolveUserId: async () => getSession()?.userId ?? null, // your auth → isolated agent context
  agentId: process.env.M8TES_AGENT_ID,       // recommended: pin embed runs to ONE mate
  // ...or, when each end-user has their OWN mate (required once the agent acts on your
  // API as that user), pin per request instead:
  // resolveAgentId: async ({ userId }) => lookupAgentFor(userId),
});

The handler is locked down by design: it forwards only the run-scoped endpoints and 403s everything else, strips any client-supplied user_id and injects the one resolveUserId returns, and drops permission_mode, tools, instructions, and model from every body. resolveUserId is required (fail-closed; null → 401): pass { singleTenant: true } only if every user should share one scope.

Wiring your auth: Clerk, Auth.js, Supabase, Express

resolveUserId receives the incoming request, so map it to your authenticated user however you already do. Never derive the user id from a client-supplied field: the proxy strips it precisely so the browser can't spoof another user.

TSX
// Clerk
import { auth } from "@clerk/nextjs/server";
resolveUserId: async () => (await auth()).userId,

// Auth.js / NextAuth v5
resolveUserId: async () => (await auth())?.user?.id ?? null,

// Supabase (SSR): read the session from your server client
// Custom JWT: verify your own cookie/header and return the subject

Non-Next frameworks (Express, Hono, Remix) use createM8tesProxy, which returns a framework-agnostic (Request) => Promise<Response> handler you adapt to your server. Full adapters: auth-adapters.md inside the package.

Render the agent: <MateChat>

<MateChat> is the full panel: thread, composer, streaming markdown, tool calls, and approval / question gates. It parses the V2 SSE wire, reconnects on drops, and de-duplicates replay (Streaming).

TSX
<MateChat
  agentId="your-mate-id"               // omit only if the route pins the mate
  runId={savedRunId}                   // optional: rejoin a thread after a refresh
  greeting="Ask me anything about your account."
  placeholder="Message the agent…"
/>

When a run pauses for approval or a question, an inline card appears; the answer resumes the run. Pausing follows the mate's permission mode. The proxy never lets the browser set it. On failure or cancel, Run failed · Retry re-sends the last message on the same run.

First-message latency: the first message of a session boots the sandbox (a few to tens of seconds); follow-ups are warm. <MateChat> shows "Connecting…" during boot; classNames.statusBar restyles that row.

Threads across a refresh. The panel holds no history. Pass a runId to rejoin.

Getting that id back is the part <MateChat> can't do for you: it accepts runId but exposes no callback for the run it just created. Two ways to obtain one:

  • Server-side (simplest). The proxy deliberately doesn't forward GET /runs. That would let a browser enumerate runs. So call client.runs.list(user_id=…) from your own backend and hand the browser the ids it may open. This is also how you build a thread list.
  • Headless. Drive useMate() (level 4 below) and read runId off the hook, then persist it yourself.

Make it yours

Four levels, lightest first; most apps stop at level 1 or 2.

1. Theme tokens. The chat reads your shadcn/Tailwind variables first and falls back to the m8tes palette, so in a shadcn app it matches your product automatically. Override anywhere above the chat:

CSS
.my-agent-panel {
  --primary: oklch(0.55 0.2 264);   /* buttons, user bubble, links */
  --radius: 0.75rem;
  --font-sans: "Inter", sans-serif; /* the embed inherits your font */
}

2. classNames. Restyle individual slots: <MateChat classNames={{ root: "rounded-2xl shadow-lg" }} />

3. components. Swap any rendered piece: bring your own avatar, tool-call card, approval dialog, or markdown renderer.

4. Headless. Skip <MateChat> and drive the hook; you render the rest:

TSX
import { useMate } from "@m8tes/react";
const {
  messages, status, isStreaming, files,
  pendingApproval, approve,        // approval gate
  pendingQuestion, answer,         // question gate. Without answer, a paused run never resumes
  send, stop, retry, canRetry,     // gate Retry on canRetry, not on status
} = useMate({ agentId, runId });

The package ships a Customizing <MateChat> reference at docs/customizing.md (open it from node_modules/@m8tes/react/docs/customizing.md after install) with the full token table, dark mode (works under a .dark ancestor), the --m8-* state-accent tokens, the Tailwind v3 bridge, and every slot and component override.

Security model (multi-tenant)

The proxy enforces: the key never reaches the browser; only the run-scoped endpoints are forwarded (a compromised session can't reconfigure your account); user_id is injected server-side so user A can never read user B's runs; and no policy escalation from the browser. What stays yours:

  1. Pin the mate: agentId for one shared mate, resolveAgentId for one mate per end-user (shown in the route snippet). Without a pin, an end-user can start runs on any non-Company-Agent mate in your account.
  2. Scope the mate's connections to the end-user, not the account (per-user OAuth). An account-level connection (your Gmail, your Stripe) means a prompt-injected end-user could act on your behalf; in an embed, the person clicking Approve is the end-user.
  3. Rate-limit in your route (every message is a run billed to your account) and cap your spend with a usage overage ceiling plus per-end-user sub-caps (per-user overrides); when an end-user hits one, <MateChat> shows a "limit reached" card.
  4. Rotate the key from the Developer dashboard if you suspect exposure.
Rate-limit example (in resolveUserId)
TypeScript
export const { GET, POST } = createM8tesHandler({
  apiKey: process.env.M8TES_API_KEY!,
  resolveUserId: async (req) => {
    const userId = (await auth()).userId;
    if (!userId || (await isRateLimited(userId))) return null; // null => 401, run never starts
    return userId;
  },
});
Troubleshooting
SymptomCauseFix
Every message returns 401resolveUserId returned null (no signed-in user)Confirm your auth resolves server-side; for an internal tool use { singleTenant: true }.
404 on a run you just createdThe run belongs to a different end-user, or the id is wrongThe proxy scopes every run to resolveUserId; make sure the same user is resolved across create + stream.
First message hangs on "Connecting…" for 30s+Cold sandbox bootExpected on the first message of a session; follow-ups are warm. Re-skin the connecting status so it reads as progress.
Stream never streams behind nginx / a CDNThe proxy response is being bufferedThe handler sets Cache-Control: no-transform + X-Accel-Buffering: no; ensure your edge honors them and doesn't buffer text/event-stream.
Installed version looks older than the docsAn old lockfile pinned a stale prereleaseReinstall with npm i @m8tes/react@latest, or copy the source via the shadcn registry: npx shadcn@latest add https://www.m8tes.ai/r/mate-chat.json.
403 on a requestThe path isn't run-scopedThe proxy only forwards /runs + run sub-routes by design; everything else is 403.

Next: Streaming & Events · Human-in-the-Loop · Users · Quick Start

Was this page helpful?