Embed a UI
Pre-1.0.
@m8tes/reactis the React UI layer for the API. Install withnpm i @m8tes/react(latesttracks 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.
"use client";
import { M8tesProvider, MateChat } from "@m8tes/react";
import "@m8tes/react/styles.css";
export function AgentPanel() {
return (
<M8tesProvider>
<MateChat />
</M8tesProvider>
);
}Install
npm i @m8tes/reactThe 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:
npx shadcn@latest add https://www.m8tes.ai/r/mate-chat.jsonAdd 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:
// 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.
// 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 subjectNon-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).
<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 callclient.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 readrunIdoff 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:
.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:
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:
- Pin the mate:
agentIdfor one shared mate,resolveAgentIdfor 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. - 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.
- 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. - Rotate the key from the Developer dashboard if you suspect exposure.
Rate-limit example (in resolveUserId)
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
Next: Streaming & Events · Human-in-the-Loop · Users · Quick Start