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 style={{ height: "min(640px, 80dvh)" }} />
</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 { 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.
// 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
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:
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:
.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, 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:
- 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)
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
Next: Streaming & Events · Human-in-the-Loop · Users · Quick Start