Install the widget
Requirements, keys, mounting on each stack, identity, CSP and first-run checks.
Requirements
| Requirement | Detail |
|---|---|
react, react-dom | 18 or 19 |
convex | >=1.25.0 <2.0.0, a peer dependency. npm 7 or newer, pnpm 8 or newer and bun install it for you. With Yarn, add it yourself. The widget opens its own connection, so it works next to a ConvexProvider of your own. |
| Module format | ESM only. You need a bundler. There is no script tag build. |
| Server components | The package starts with "use client", so a Next.js server component can render it. |
@mediansh/agent-tools | Node 18 or newer. ESM and CommonJS. It uses only Web Crypto, so it also runs on edge runtimes. |
Packages
| Package | Install when | Gives you |
|---|---|---|
@mediansh/widget | Always | MedianSupport, MedianSupportModal, MedianContactForm, MedianFeedback, their mount functions, and the stylesheet |
@mediansh/agent-tools | Your app has a server | publicKeyFromMedianKey, medianIdentity, signMedianUser, and custom tools |
@mediansh/widget @mediansh/agent-toolsImport the stylesheet once, next to your global CSS. It styles the widget and nothing else on your page.
import "@mediansh/widget/styles.css";Environment variables
| Variable | Required | Read by | Value |
|---|---|---|---|
MEDIAN_KEY | Yes, on the server | medianIdentity(), median(), createMedianHandler(), signMedianUser() | The median_key_ value from Settings → API |
MEDIAN_TOOLS_URL | No | median(), when it registers your tool route | The route's public URL. A bare origin such as https://example.com takes the path from the request. |
MEDIAN_API_URL | No | median() and the CLI. Either https://api.median.sh or https://api.median.sh/v1 works for both. | Leave it unset. |
On runtimes without process.env, such as Cloudflare Workers, pass the key in
code.
medianIdentity(resolve, { key: env.MEDIAN_KEY });
median(config, { key: env.MEDIAN_KEY });:::danger
Never put MEDIAN_KEY behind NEXT_PUBLIC_, VITE_ or PUBLIC_. Those
prefixes ship the value to every visitor, and the key signs identities and tool
calls. Only the median_pk_ public key belongs in the browser.
:::
Get the key into the browser
Create a key in Settings → API. You get two values.
| Key | Prefix | Where it goes |
|---|---|---|
| Median key | median_key_ | Your server, as MEDIAN_KEY. Shown once. |
| Public key | median_pk_ | The browser, as the widget's apiKey. Derived from the Median key. |
| Your app | How the widget gets the public key |
|---|---|
| Renders on a server | Derive it with publicKeyFromMedianKey(process.env.MEDIAN_KEY) and pass it as apiKey. Only MEDIAN_KEY is stored. |
| Can serve an API route | Serve medianIdentity() from a route and pass its path as identity. The route answers with the key, plus the signed user when someone is signed in. See Identify users. |
| Has no server | Paste the median_pk_ value into a browser env var such as VITE_MEDIAN_PUBLIC_KEY. Tools and signed identity need a server. |
publicKeyFromMedianKey throws on a bad value:
| Value | Error |
|---|---|
| Wrong prefix | Median: MEDIAN_KEY must start with median_key_. Copy the Median key from Settings under API. |
| Right prefix, wrong shape | Median: MEDIAN_KEY is malformed. Copy it again from Settings under API. |
Mount it
Mount one widget, once, near the root of your app. It sits in the bottom right corner.
Next.js App Router
import { MedianSupport } from "@mediansh/widget";
import { publicKeyFromMedianKey } from "@mediansh/agent-tools";
export function Support() {
const key = process.env.MEDIAN_KEY;
if (!key) return null;
return <MedianSupport apiKey={publicKeyFromMedianKey(key)} />;
}import "@mediansh/widget/styles.css";
import { Support } from "./support";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Support />
</body>
</html>
);
}Support has no "use client", so it is a server component and MEDIAN_KEY
stays on the server. A static layout renders at build time, so set
MEDIAN_KEY in the build environment too.
Next.js Pages Router
Pages have no server component to derive the key in, so serve it from an identity route.
import { medianIdentity } from "@mediansh/agent-tools";
export const config = { runtime: "edge" };
// Return the signed in user's id, or null. Null answers with the key alone.
export default medianIdentity(async () => null).GET;Keep runtime: "edge". medianIdentity answers a Request with a
Response, which a Pages Router API route only accepts on the edge runtime.
import "@mediansh/widget/styles.css";
import { MedianSupport } from "@mediansh/widget";
import type { AppProps } from "next/app";
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Component {...pageProps} />
<MedianSupport identity="/api/median/identity" />
</>
);
}Vite or client-only React
VITE_MEDIAN_PUBLIC_KEY=median_pk_...import "@mediansh/widget/styles.css";
import { MedianSupport } from "@mediansh/widget";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<>
<App />
<MedianSupport apiKey={import.meta.env.VITE_MEDIAN_PUBLIC_KEY} />
</>,
);Only the public key goes in a VITE_ variable. @mediansh/agent-tools is not
needed here.
Without React
import "@mediansh/widget/styles.css";
import { mountMedianSupport } from "@mediansh/widget";
const support = mountMedianSupport({ apiKey: "median_pk_..." });For Vue, Svelte, Rails or plain HTML with a bundler. It takes the same props as
<MedianSupport> and appends its own container to <body>. react and
react-dom must still be installed. Call it in the browser, after the page
exists. update() and unmount() are on
Control from code.
Errors inside the widget never break your page. The widget hides itself and
logs the reason to the console. Code you run on your own server, such as
publicKeyFromMedianKey, is outside that guard.
Identify users
| Option | Code | Your team sees | Follows the person across devices | Tools get a verified user |
|---|---|---|---|---|
| Anonymous | apiKey only | An anonymous visitor, plus the email they type in | No. The thread belongs to the browser. | No |
Unsigned user | user={{ name, email, avatarUrl, metadata }} | The fields you pass | No | No |
| Signed identity route | identity="/api/median/identity" | The fields you return, tied to your user id | Yes | Yes |
The identity route is one line:
import { auth } from "@clerk/nextjs/server";
import { medianIdentity } from "@mediansh/agent-tools";
export const { GET } = medianIdentity(async () => (await auth()).userId);<MedianSupport identity="/api/median/identity" />The route answers with the public key and the signed user, so apiKey is
optional. Pass both and the launcher shows before the route answers. Changes to
user sync on their own. A field you stop sending keeps its last value.
Identity covers returning more than an id, signing yourself, and requiring sign in.
Reset on sign out
import { medianSupport } from "@mediansh/widget";
async function signOut() {
await auth.signOut();
medianSupport.reset();
}reset() removes this browser's session and saved email, and signs out the user
the widget holds. See Identity.
Content Security Policy
If your site sends a Content Security Policy, add these sources:
connect-src 'self' https://handsome-hare-949.convex.cloud wss://handsome-hare-949.convex.cloud https://cloud.median.sh;
img-src 'self' https://cloud.median.sh https://img.clerk.com https://cdn.discordapp.com https://*.slack-edge.com https://secure.gravatar.com data: blob:;| Directive | Source | Used for |
|---|---|---|
connect-src | wss://handsome-hare-949.convex.cloud, https://handsome-hare-949.convex.cloud | Messages, replies and typing |
connect-src | https://cloud.median.sh | Uploading attachments and screenshots |
connect-src | 'self' | Your identity route, when it is on the same origin |
img-src | https://cloud.median.sh | Attachments and the agent's picture |
img-src | https://img.clerk.com | Teammates' pictures |
img-src | https://cdn.discordapp.com, https://*.slack-edge.com, https://secure.gravatar.com | Pictures of teammates who reply from Discord or Slack without a linked account |
img-src | data:, blob: | Screenshots, and previews of files before they are sent |
A blocked picture shows as a letter. Feedback and crash report screenshots
redraw your page from the DOM, so an image or font your policy will not fetch
comes out blank or in a fallback font. When the agent asks to see the screen,
the widget uses the browser's screen sharing. A Permissions-Policy that
blocks display-capture makes it redraw the page instead.
Match your colors
The widget reads shadcn-style CSS variables from your page, such as --primary
and --card, and uses a light theme without them. See
Theming.
Bundle size
The launcher loads with your page. The markdown renderer loads when a visitor
points at or focuses the launcher, when the panel opens, or when the page goes
idle with an answered thread behind the button. The screenshot code loads the
first time a picture is taken. Do not wrap the component in dynamic() or
lazy(). That only delays the launcher.
Verify the install
Load a page
The launcher is in the bottom right corner. The console has no errors that mention Median.
Send a message
The widget asks for an email before the first message. It skips this when
user.email is set, requireEmail is false, or Ask for an email is
off in Agent → Behavior.
Find it in the inbox
Open Inbox in the dashboard. The thread is under Open. If the agent handed it over, it is also under Needs a person.
Troubleshooting
| Symptom | Console | Fix |
|---|---|---|
| No launcher | Median: <MedianSupport> was given neither apiKey nor identity, so nothing is on the page. Pass the median_pk_ id from publicKeyFromMedianKey(MEDIAN_KEY), or the path of a route built with medianIdentity(). | Pass apiKey or identity. Check that the env var holding the key is set at build and at runtime. In the App Router example, a missing MEDIAN_KEY renders nothing and logs nothing. |
| No launcher | Median: the apiKey passed to <MedianSupport> is "median_key_12345…", which does not look like the browser-safe id from publicKeyFromMedianKey(MEDIAN_KEY). It should start with median_pk_. | Pass the median_pk_ public key. If a median_key_ value reached the browser, revoke it in Settings → API and create a new key. |
| No launcher | Median did not recognize the apiKey passed to <MedianSupport>, so it has been hidden. Derive it from an active MEDIAN_KEY with publicKeyFromMedianKey. | The key was revoked. Create a new one in Settings → API. |
| No launcher, or the visitor stays anonymous | Median: could not read the identity route. /api/median/identity answered 500. It should return { apiKey, user } from medianIdentity(). | Open the route in a browser. missing_median_key means MEDIAN_KEY is not set. invalid_median_key means it is not a full median_key_ value. |
| Widget is unstyled and sits inside your layout | None | Import @mediansh/widget/styles.css once. |
More widget problems are on Support widget. Dashboard problems are on Troubleshooting.