The protocol lets Mail, Calendar, CRM, Booking and every other Mayfair app give Artemii actions. Artemii calls your endpoint with the person's Mayfair ID access token, so your app decides what that person may do exactly as it does for its own UI.
How it works
- Your app publishes a manifest (JSON) at a URL on an approved Mayfair domain, conventionally
/.well-known/mayfair-app.json. - A Mayfair administrator registers it once. The response shows a signing secret (
mfs_...) once. Give it to your backend. - Every signed-in person then sees your app in their connected apps, with a permission level (default ask). Its tools reach the model as
<app id>__<tool>. - When the model calls a tool, Artemii sends
POST {baseUrl}/tools/{toolName}with the person's token. Read-only tools run. Tools that change something ask the person first (unless they set the app to allow), and are blocked at level read.
The manifest
{
"schema": "mayfair-app/1",
"id": "mail",
"name": "Mayfair Mail",
"description": "Your Mayfair mailbox.",
"version": "1.0.0",
"baseUrl": "https://mail.mayfairmail.com/api/artemii",
"audience": "mayfair-ai",
"clientIds": ["mayfair-mail"],
"memory": { "read": true, "write": true },
"tools": [
{
"name": "search_mail",
"description": "Search the signed-in person's mail.",
"readOnly": true,
"inputSchema": { "type": "object", "properties": { "query": { "type": "string" } }, "required": ["query"] }
},
{
"name": "send_mail",
"description": "Send an email from the signed-in person's account.",
"readOnly": false,
"timeoutMs": 20000,
"inputSchema": { "type": "object", "properties": { "to": { "type": "string" }, "subject": { "type": "string" }, "body": { "type": "string" } }, "required": ["to", "subject"] }
}
]
}id: 2 to 15 lowercase letters or digits, starting with a letter. A few names are reserved (such asweb,files,memory,github,slack).- 1 to 40 tools. Names match
[a-z][a-z0-9_]{0,39}. - Every tool must say
readOnlytrue or false. Be honest: false for anything that sends, changes or deletes. inputSchemais a JSON Schema of type object (at most 8 KB). Artemii enforces it before the call.baseUrlmust be on an approved host (by defaultmayfairmail.comand its subdomains), with no query or fragment. The host is checked at registration and again before every call; the token is never sent anywhere else.clientIdsare the OAuth client ids your own tokens carry. They are needed for the memory API.
What your endpoint receives
POST https://mail.mayfairmail.com/api/artemii/tools/search_mail
Authorization: Bearer <the person's Mayfair ID access token>
Content-Type: application/json
X-Mayfair-Delegate: artemii
X-Mayfair-Request-Id: <random>
X-Mayfair-Signature: t=<unix seconds>,v1=<base64url HMAC-SHA256 of "<t>.<raw body>">
{ "arguments": { "query": "invoice" },
"context": { "userId": "<sub>", "runId": "...", "chatId": "...", "tool": "search_mail" } }Your endpoint must:
- Verify the token like for any Mayfair ID call: ES256 signature against the Mayfair ID keys, issuer, expiry, and an audience that includes
mayfair-ai(this token is meant for Artemii; accept it only on this route). Usesubas the identity, nevercontext.userIdalone. - Verify the signature. It proves the request came from Artemii and not from someone who got hold of a token. Constant-time compare; reject if
tis more than 5 minutes old. - Check the person's own permissions for the action, as your UI does.
- Log it as a delegate: "via Artemii" in your audit trail.
import { createHmac, timingSafeEqual } from 'node:crypto';
// header: the X-Mayfair-Signature value, rawBody: the request body exactly as received
export function verifyArtemiiSignature(header, rawBody, signingSecret, nowSeconds = Math.floor(Date.now() / 1000)) {
const t = Number(/(?:^|,)t=(\d+)/.exec(header || '')?.[1]);
const v1 = /(?:^|,)v1=([A-Za-z0-9_-]+)/.exec(header || '')?.[1];
if (!Number.isFinite(t) || !v1 || Math.abs(nowSeconds - t) > 300) return false; // reject anything older than 5 minutes
const expected = createHmac('sha256', signingSecret).update(`${t}.${rawBody}`).digest('base64url');
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && timingSafeEqual(a, b); // constant-time compare
}A complete route, using the jose library for the token check:
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { verifyArtemiiSignature } from './verify-signature.js';
const jwks = createRemoteJWKSet(new URL('https://mayfairmail.com/.well-known/jwks.json'));
// POST /api/artemii/tools/:name (a standard Request / Response handler)
export async function handleTool(request, toolName) {
const raw = await request.text();
if (!verifyArtemiiSignature(request.headers.get('x-mayfair-signature'), raw, process.env.ARTEMII_SIGNING_SECRET)) {
return new Response('Bad signature', { status: 401 });
}
let claims;
try {
const token = (request.headers.get('authorization') || '').replace(/^Bearer /, '');
({ payload: claims } = await jwtVerify(token, jwks, {
issuer: 'https://mayfairmail.com',
audience: 'mayfair-ai', // this token is meant for Artemii: accept it only on this route
algorithms: ['ES256'],
typ: 'at+jwt',
}));
} catch {
return new Response('Bad token', { status: 401 });
}
const { arguments: args } = JSON.parse(raw);
// claims.sub is the person. Check what they may do, exactly as your own UI does, and log the call "via Artemii".
if (toolName === 'search_mail') {
return Response.json({ ok: true, result: await searchMail(claims.sub, String(args.query ?? '')) });
}
return Response.json({ ok: false, error: { message: 'Unknown tool.' } }, { status: 404 });
}Answering
HTTP 200 with { "ok": true, "result": <string or JSON> }, or { "ok": false, "error": { "message": "..." } } (shown to the model, so keep it free of secrets). Use 401 when the token is not accepted and 403 when the person may not do this.
{ "ok": true, "result": "3 messages match \"invoice\"." }{ "ok": false, "error": { "message": "That folder does not exist." } }Results are limited to 256 KB and are treated as untrusted data by the model, so never put instructions in them. Summarise on your server where you can.
Steps for an app team
- Add
POST /api/artemii/tools/:name. Verify token and signature, then dispatch to the same service functions your UI uses, with the person's identity. - Serve the manifest. List only actions you are happy for an AI to do, and mark every change
readOnly: false. - Make sure your host is on the approved list.
- Ask a Mayfair administrator to register it. Store the signing secret in your secret store (it is not shown again; a new one can be issued).
- After you change the manifest, ask for a refresh.
- To share memory too, request tokens with the scope
ai memoryand use the memory API. - Test with a real signed-in account: ask Artemii for a read action, then a write. Check that the write asks and that your audit log shows the call.
Limits
- Only Mayfair ID sessions get app tools. API keys and service tokens do not.
- The token expires with the person's access token (about 15 minutes). A long conversation may need a reload; your app answers 401 and the model tells the person to sign in again.
- This is token forwarding, not RFC 8693 token exchange.
Permissions apply to your app too
People control every app from Settings. Level off hides the app's tools, read lets only read-only tools run, ask asks before each change and allow runs without asking. Administrators can set a ceiling. See Privacy and control.