<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
- Host the built files with your app (or from your CDN).
- Add the element and the script:
<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>- Create
artemii-setup.js(about ten lines):
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
| Attribute | Property | Notes |
|---|---|---|
base-url | baseUrl | The gateway address (required). |
model | model | A public model id. Default: the gateway's default. The person's own pick in the model menu wins and is remembered. |
app-name | appName | Shown under the name and told to the model. |
theme | theme | light, dark, or omit to follow prefers-color-scheme. |
placement | placement | right (default) or left. |
heading | Empty-state title (default "How can I help?"). | |
suggestions | suggestions | JSON attribute or array of strings or {label, prompt}. Shown as chips; a click sends. |
hide-launcher | No floating button. Open it from your own UI with panel.open(). | |
hide-model-picker | Hide the model menu (use model). | |
modal | Behave as a modal dialog (focus trap, scrim) on desktop too. Phones always do. | |
open | isOpen (read only) | Reflects the state. Set the attribute to open. |
getToken | async (forceRefresh) => accessToken, called before every request. | |
instructions | Text added to the system prompt. Cut at 6,000 characters. | |
messages, busy | Read 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).
| Event | detail |
|---|---|
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:model | Small payloads. |
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 personEvents 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
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:
- The model answers with
tool_calls. Each becomes an inline action card showing the name, what it will do and the arguments. - Arguments are checked against your JSON Schema (type, enum, required, min/max, length, pattern, items). Invalid arguments never reach
run. - If
requiresApproval(orsideEffect) 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. run(args, { signal, panel })runs and its result goes back to the model.- 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.
| Property | What it controls |
|---|---|
--artemii-bg, --artemii-soft, --artemii-soft-2 | Surface, cards and code, bubbles. |
--artemii-fg, --artemii-muted, --artemii-border | Text, secondary text, hairlines. |
--artemii-accent, --artemii-ring | Amber accent and focus ring. |
--artemii-primary, --artemii-on-primary | Primary buttons (Send, Allow). |
--artemii-radius | Card radius (default 26px). |
--artemii-font | Font stack. |
--artemii-width, --artemii-offset, --artemii-z | Drawer 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.
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:
<!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_planotherwise).
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:
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 withdescribe. - 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 onlyhttp(s):,mailto:andtel: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 see | Cause 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 error | The 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 invisible | The element was not upgraded (script blocked, wrong path), or your page beats its z-index: set --artemii-z. |
| Tool never runs | The 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.