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 style={{ height: "min(640px, 80dvh)" }} />
    </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 { auth } from "@clerk/nextjs/server"; // or your existing server auth
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 () => (await auth()).userId, // signed-in user → 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 Clerk example assumes Clerk is already configured in your app; adapt resolveUserId to your existing server auth using the examples below. Set M8TES_API_KEY in .env.local and sign in before sending a message. The provider defaults to /api/m8tes; set its endpoint prop if you mount the proxy elsewhere.

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
  onRunIdChange={saveRunId}            // save new run IDs in your app
  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. After a failed, incomplete, or cancelled turn, Retry re-sends the last message on the same run when one was created. Retry is available only for a message sent in the current browser session; a restored run has no local message to replay. Temporary connection drops reconnect automatically; if they persist, Reconnect rejoins the current run without sending duplicate work. Temporary decision-submission failures keep the card available to retry.

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. Save new IDs with onRunIdChange and pass a runId to restore the conversation. Keep saved IDs scoped to the signed-in user. If a completed run can no longer stream, the panel restores its final output; this fallback is not a full historical transcript.

For a thread list, call client.runs.list(user_id=…) from your backend and return the IDs that user may open. The browser proxy deliberately does not expose run listing.

Floating chat: <MateWidget>

Use the same provider and stylesheet with a launcher and responsive panel:

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

<M8tesProvider>
  <MateWidget
    title="Ask Acme"
    heading="How can we help?"
    starters={[{ label: "Get started", message: "Help me get started." }]}
    onRunIdChange={saveRunId}
  />
</M8tesProvider>

Closing the widget or returning home keeps the conversation, draft, and stream. Reopen and choose Continue conversation. Pass runId to restore a saved thread, loading or error for server session setup, and change the component's React key to start fresh (including when the signed-in user changes). The widget fills the screen on phones. The panel version, MateChat, needs a height from your layout or its style prop.

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, canSend,                       // gate your composer on canSend
  stop, retry, canRetry,               // gate Retry on canRetry, not on status
  isStopping, stopError,               // cancellation acknowledgement and retryable failure
  isDisconnected, connectionError, resume, // rejoin after a drop, without resending
} = useMate({ agentId, runId });

stop() requests cancellation; disable actions while isStopping is true. If it fails, show stopError and let the user try Stop again while the hook rejoins the active run. For custom decision cards, await approve() / answer() and catch rejections before showing a decision as applied.

When automatic reconnects run out, isDisconnected goes true and canSend goes false: the server may still be executing that run, so resume() (or retry()) rejoins it instead of re-sending the prompt. connectionError carries the reason to show.

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
import { auth } from "@clerk/nextjs/server"; // or your existing server auth
import { createM8tesHandler } from "@m8tes/react/server";

export const { GET, POST } = createM8tesHandler({
  apiKey: process.env.M8TES_API_KEY!,
  resolveUserId: async (req) => {
    const userId = (await auth()).userId;
    // isRateLimited: your limiter (e.g. Upstash Ratelimit)
    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 } and turn strict mode off (require_end_user_id: false), or every run 422s with END_USER_ID_REQUIRED.
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?