Median

Install the widget

Requirements, keys, mounting on each stack, identity, CSP and first-run checks.

Updated Oct 1, 20266 minute read

Requirements

RequirementDetail
react, react-dom18 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 formatESM only. You need a bundler. There is no script tag build.
Server componentsThe package starts with "use client", so a Next.js server component can render it.
@mediansh/agent-toolsNode 18 or newer. ESM and CommonJS. It uses only Web Crypto, so it also runs on edge runtimes.

Packages

PackageInstall whenGives you
@mediansh/widgetAlwaysMedianSupport, MedianSupportModal, MedianContactForm, MedianFeedback, their mount functions, and the stylesheet
@mediansh/agent-toolsYour app has a serverpublicKeyFromMedianKey, medianIdentity, signMedianUser, and custom tools
@mediansh/widget @mediansh/agent-tools

Import 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

VariableRequiredRead byValue
MEDIAN_KEYYes, on the servermedianIdentity(), median(), createMedianHandler(), signMedianUser()The median_key_ value from Settings → API
MEDIAN_TOOLS_URLNomedian(), when it registers your tool routeThe route's public URL. A bare origin such as https://example.com takes the path from the request.
MEDIAN_API_URLNomedian() 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.

KeyPrefixWhere it goes
Median keymedian_key_Your server, as MEDIAN_KEY. Shown once.
Public keymedian_pk_The browser, as the widget's apiKey. Derived from the Median key.
Your appHow the widget gets the public key
Renders on a serverDerive it with publicKeyFromMedianKey(process.env.MEDIAN_KEY) and pass it as apiKey. Only MEDIAN_KEY is stored.
Can serve an API routeServe 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 serverPaste 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:

ValueError
Wrong prefixMedian: MEDIAN_KEY must start with median_key_. Copy the Median key from Settings under API.
Right prefix, wrong shapeMedian: 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

app/support.tsx
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)} />;
}
app/layout.tsx
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.

pages/api/median/identity.ts
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.

pages/_app.tsx
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

.env
VITE_MEDIAN_PUBLIC_KEY=median_pk_...
src/main.tsx
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

main.ts
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

OptionCodeYour team seesFollows the person across devicesTools get a verified user
AnonymousapiKey onlyAn anonymous visitor, plus the email they type inNo. The thread belongs to the browser.No
Unsigned useruser={{ name, email, avatarUrl, metadata }}The fields you passNoNo
Signed identity routeidentity="/api/median/identity"The fields you return, tied to your user idYesYes

The identity route is one line:

app/api/median/identity/route.ts
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:

Content-Security-Policy
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:;
DirectiveSourceUsed for
connect-srcwss://handsome-hare-949.convex.cloud, https://handsome-hare-949.convex.cloudMessages, replies and typing
connect-srchttps://cloud.median.shUploading attachments and screenshots
connect-src'self'Your identity route, when it is on the same origin
img-srchttps://cloud.median.shAttachments and the agent's picture
img-srchttps://img.clerk.comTeammates' pictures
img-srchttps://cdn.discordapp.com, https://*.slack-edge.com, https://secure.gravatar.comPictures of teammates who reply from Discord or Slack without a linked account
img-srcdata:, 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

SymptomConsoleFix
No launcherMedian: <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 launcherMedian: 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 launcherMedian 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 anonymousMedian: 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 layoutNoneImport @mediansh/widget/styles.css once.

More widget problems are on Support widget. Dashboard problems are on Troubleshooting.

Still need help?

    Esc