Developers Embeddable assistant

Embeddable assistant.

A copilot panel for any Mayfair app, with one script tag.

<artemii-panel> is a Copilot-style assistant panel. People are already signed in with Mayfair ID, so the panel talks to the Artemii API with their access token and usage counts against their plan, exactly like the Artemii app.

  • A Web Component with Shadow DOM (your CSS cannot break it and it cannot break yours), zero dependencies, no inline script or style, no eval.
  • A launcher button (the Orbit mark) opens a right-hand drawer on desktop and a bottom sheet on phones. Light and dark.
  • Streaming chat, stop, regenerate, copy, new chat, model picker, usage meter and safe Markdown.
  • Your app tells it what the person is looking at (shown as a Using: ... chip with an on/off switch) and gives it tools. The panel runs the tool loop in the browser and asks the person to Allow or Deny actions with side effects.

Availability

The built files (artemii-panel.min.js and mayfair-id.min.js) are supplied by Mayfair to the teams building Mayfair apps. There is not yet a public CDN address. Email hello@mayfairmail.com to get them.

Quick start

  1. Host the built files with your app (or from your CDN).
  2. Add the element and the script:
HTML
<artemii-panel base-url="https://mayfair-ai.crelloo.workers.dev" app-name="Mayfair Mail"></artemii-panel>
<script type="module" src="/vendor/artemii-panel.min.js"></script>
<script type="module" src="/artemii-setup.js"></script>
  1. Create artemii-setup.js (about ten lines):
JavaScript
import { createTokenProvider } from '/vendor/mayfair-id.min.js';

await customElements.whenDefined('artemii-panel');
const panel = document.querySelector('artemii-panel');

const id = createTokenProvider({
  issuer: 'https://mayfairmail.com',
  clientId: 'mayfair-mail',
  redirectUri: location.origin + '/mayfair-callback.html',
});
panel.getToken = id.getToken;                 // a Mayfair ID access token, never a service key
panel.instructions = 'You help the user with their email. Be brief.';
panel.suggestions = ['Summarise this thread', 'Draft a reply'];
panel.setContext(() => ({
  title: thread.subject,
  summary: thread.preview,
  selection: String(getSelection()),
  data: { id: thread.id },
}));

If your app already has a Mayfair ID access token, skip the helper: panel.getToken = async (forceRefresh) => myAuth.accessToken({ forceRefresh }). Always wait for customElements.whenDefined('artemii-panel') before calling methods. Properties assigned earlier are kept when the element upgrades.

Attributes and properties

AttributePropertyNotes
base-urlbaseUrlThe gateway address (required).
modelmodelA public model id. Default: the gateway's default. The person's own pick in the model menu wins and is remembered.
app-nameappNameShown under the name and told to the model.
themethemelight, dark, or omit to follow prefers-color-scheme.
placementplacementright (default) or left.
headingEmpty-state title (default "How can I help?").
suggestionssuggestionsJSON attribute or array of strings or {label, prompt}. Shown as chips; a click sends.
hide-launcherNo floating button. Open it from your own UI with panel.open().
hide-model-pickerHide the model menu (use model).
modalBehave as a modal dialog (focus trap, scrim) on desktop too. Phones always do.
openisOpen (read only)Reflects the state. Set the attribute to open.
getTokenasync (forceRefresh) => accessToken, called before every request.
instructionsText added to the system prompt. Cut at 6,000 characters.
messages, busyRead only: the conversation, and whether a reply or tool run is in progress.

api-key is not an attribute you can use. It is ignored and reported as an artemii:error (api_key_not_allowed). Service keys never belong in a browser.

Methods and events

Methods: open(), close(), toggle(), send(text), stop(), reset(), regenerate(), setContext(fn), refreshContext(), registerTools(tools) (returns an unregister function) and unregisterTools(names).

Eventdetail
artemii:message{ role, content, context?, model? }. Assistant messages fire once, when the reply is complete.
artemii:tool{ phase, id, name, arguments, result?, error? } where phase is request, approved, denied, result or error.
artemii:error{ error, code, status, message, retryAfter? }
artemii:open, artemii:close, artemii:reset, artemii:modelSmall payloads.
JavaScript
panel.addEventListener('artemii:tool', (e) => console.log(e.detail.phase, e.detail.name)); // request, approved, denied, result, error
panel.addEventListener('artemii:error', (e) => console.warn(e.detail.code, e.detail.message));
panel.send('Summarise this thread');            // opens the panel and sends as the person

Events bubble and are composed, so you can listen on any ancestor.

What the person is looking at: setContext

Your function is called when the panel opens, on pointer-down inside the panel (so a text selection is captured before focus clears it), on refreshContext() and on every send. Everything is optional; return null to share nothing. Limits: title 120, summary 3,000, selection 2,500 and data 2,500 characters.

The person always sees it: a chip Using: <title> above the composer. Clicking it shows exactly what is shared, and its switch turns sharing off. It is sent as a system message wrapped in <context> and labelled as data, not instructions. The gateway does not store it. Do not put secrets, or data the person may not see, in it.

Tools: app actions

JavaScript
panel.registerTools([
  {
    name: 'find_contact',
    description: 'Search the address book.',
    parameters: { type: 'object', properties: { query: { type: 'string', maxLength: 200 } }, required: ['query'] },
    run: ({ query }) => contacts.search(query), // read only: no approval needed
  },
  {
    name: 'draft_reply',
    title: 'Draft a reply',
    description: 'Put a draft in the reply box. The user reviews and sends it.',
    parameters: { type: 'object', properties: { body: { type: 'string', maxLength: 4000 } }, required: ['body'] },
    requiresApproval: true,                      // the person sees Allow / Deny first
    describe: (args) => `Write ${args.body.length} characters into the reply box.`,
    run: async ({ body }, { signal }) => { replyBox.value = body; return { ok: true }; },
  },
]);

The loop runs in the browser:

  1. The model answers with tool_calls. Each becomes an inline action card showing the name, what it will do and the arguments.
  2. Arguments are checked against your JSON Schema (type, enum, required, min/max, length, pattern, items). Invalid arguments never reach run.
  3. If requiresApproval (or sideEffect) applies, the card shows Allow and Deny and nothing runs until the person decides. Deny tells the model not to try again unless asked. Stop cancels a waiting card.
  4. run(args, { signal, panel }) runs and its result goes back to the model.
  5. At most 6 rounds. Tool results are cut at 12,000 characters. Calls in one round run one after another.

Read-only tools (search, read the open item) should not ask. Anything that changes data or leaves the page (send, delete, post, pay) must.

Theming

Set these custom properties on the element or an ancestor. Defaults follow the Artemii app: neutral surfaces with amber only as a small accent.

PropertyWhat it controls
--artemii-bg, --artemii-soft, --artemii-soft-2Surface, cards and code, bubbles.
--artemii-fg, --artemii-muted, --artemii-borderText, secondary text, hairlines.
--artemii-accent, --artemii-ringAmber accent and focus ring.
--artemii-primary, --artemii-on-primaryPrimary buttons (Send, Allow).
--artemii-radiusCard radius (default 26px).
--artemii-fontFont stack.
--artemii-width, --artemii-offset, --artemii-zDrawer width (420px), launcher offset (20px), z-index.

Dark mode follows prefers-color-scheme unless theme is set. Motion respects prefers-reduced-motion.

Mayfair ID helper

mayfair-id.js gets a Mayfair ID access token with authorization code and PKCE (S256). The token stays in memory and sessionStorage, never in localStorage or a URL, and is renewed 30 seconds before it expires through a hidden frame.

JavaScript
import { createTokenProvider } from './mayfair-id.js';

const id = createTokenProvider({
  issuer: 'https://mayfairmail.com',
  clientId: 'mayfair-mail',
  redirectUri: location.origin + '/mayfair-callback.html',
  scope: 'openid profile email plan ai',
});
document.querySelector('#sign-in').addEventListener('click', () => id.signIn()); // call from a click so the popup is allowed
const token = await id.getToken();

The redirect page only has to load the helper. Register it with Mayfair ID exactly as you use it:

HTML
<!doctype html><title>Signing in</title><script type="module" src="/vendor/mayfair-id.min.js"></script>

The redirect page must be allowed to be framed by your own origin (frame-ancestors 'self') for silent renewal. Errors are MayfairIdError with code: login_required, access_denied, state_mismatch, iss_mismatch, token_exchange_failed, network or timeout.

Setting up the gateway for a new app

  • The app's origin must be on the gateway's allowed origins.
  • If the app has its own Mayfair ID client id, the gateway must accept it as an audience.
  • Each person needs a plan (403 no_plan otherwise).

Mayfair administrators do both. Tell us the origin and client id.

Content Security Policy

The panel needs no unsafe-inline and no unsafe-eval. A page that already allows scripts from your origin needs only:

CSP
script-src 'self';
connect-src https://mayfair-ai.crelloo.workers.dev https://mayfairmail.com;
frame-src https://mayfairmail.com 'self';

frame-src is only for silent token renewal. img-src and font-src are not needed: no images are loaded from model output and the Orbit mark is inline SVG.

Security notes

  • Never ship a service key in a page, a bundle or a repository. The panel and the SDK refuse them.
  • Validate tool arguments yourself in run. They come from a model that read untrusted text. Bounds-check ids, never build HTML or queries from them, never let a tool do what the person could not.
  • Approvals are the safety net. Mark every tool that sends, deletes, pays, shares or changes data requiresApproval: true, and keep the card text honest with describe.
  • Model output is untrusted. The panel renders it with a minimal Markdown renderer that builds DOM nodes (no innerHTML), shows raw HTML as text, loads no images and keeps only http(s):, mailto: and tel: links.
  • Send only what the feature needs as context and instructions. Anything that can run script on your page can read the token, so keep your own CSP strict.

Troubleshooting

You seeCause and fix
"This app hasn't connected Mayfair ID to Artemii yet."panel.getToken is not set. Set it before the first send.
"Can't reach Artemii" and a CORS errorThe page origin is not allowed by the gateway, or connect-src does not list it.
"This app isn't allowed to use Artemii yet"403 origin_not_allowed: same cause.
"Your Mayfair ID session has expired"The gateway rejected the token twice: expired, wrong audience, missing ai scope or wrong issuer.
"Your Mayfair plan doesn't include Artemii"403 no_plan.
"You've used today's Artemii allowance"429 limit_day or limit_week. It resets as stated.
"Artemii is waking up"503 model_starting. Retry shortly.
Panel unstyled or invisibleThe element was not upgraded (script blocked, wrong path), or your page beats its z-index: set --artemii-z.
Tool never runsThe name does not match /^[A-Za-z0-9_-]{1,64}$/, the arguments failed your schema (the card says so), or the person denied it.

Not included yet

Image or file attachments, conversation history across page loads (the conversation lives in the page), voice, multi-panel coordination and a getToken for service accounts.