Waymaker
WaymakerDocs
Developer Documentation
Documentation

Context providers

When someone in your organisation opens an email in a Commander shared inbox, your app can show them what it knows about the sender: the account, its ARR and its open cases, as a card beside the email. Commander asks your app one question, for one viewer, and shows the card you answer with.

You never receive the email. You can't change anything in Commander. You answer from your own data, in your own server code.

Before you start

  • Your app has end-user sign-in. Commander only asks about a viewer your app already holds as a verified user with the same email address as their verified Waymaker address. Nobody else's opens reach you. Without sign-in, turning it on is refused with "This app needs Waymaker sign-in to be a context provider."
  • Your app is on the web, not only inside workspaces. Commander calls its production address: your live custom domain, or the app's own Waymaker address. Never a preview or staging address.
  • Your server answers POST /__waymaker/context. The path is fixed. Serve it from your app's own server code, because only your app knows its accounts and cases.

1. Turn it on

Only an organisation owner or admin can do this.

waymaker host apps context-provider on <app-id>

From an agent, use the MCP tool host_app_context_provider_set with app_id and enabled: true.

The first time, the response holds the app's signing secret (whsec_…). It is shown once. Store it in the app's environment straight away, for example:

waymaker host apps env set <app-id> WAYMAKER_CONTEXT_SECRET=whsec_...

To stop, run waymaker host apps context-provider off <app-id>. The secret is kept, so turning it on again needs no new secret.

2. The request

POST /__waymaker/context HTTP/1.1
Host: crm.yourcompany.example
Content-Type: application/json
User-Agent: Waymaker-Context/1
Waymaker-Context-Id: 7d1c2f0a-4b3e-4c55-9a61-2f8e0b9d3c47
Waymaker-Timestamp: 1767225600
Waymaker-Signature: v1=94e797473f5308870b3741e5e3e87d6734d33696b56f28acd1c9de8faaf0c8c2
{
  "request_id": "7d1c2f0a-4b3e-4c55-9a61-2f8e0b9d3c47",
  "app_id": "3f6b8a2e-1c4d-4e7f-8a9b-0c1d2e3f4a5b",
  "org": "org_example",
  "viewer": { "sub": "user_example", "email": "sam@yourcompany.example" },
  "subject": { "kind": "email", "value": "jane@customer.example" },
  "surface": "inbox_panel",
  "locale": "en-AU",
  "timezone": "Australia/Brisbane"
}
KeyWhat it is
request_idA new id for every request, the same as Waymaker-Context-Id.
app_idYour app's id.
orgThe id of the organisation the viewer is working in (org_…). It never changes, so key your data on it if your app serves more than one organisation.
viewer.emailThe viewer's verified address, lower-cased. Your app holds a verified user with this address.
viewer.subThe viewer's Waymaker user id.
subject.kindemail or domain.
subject.valueThe address or domain the viewer is looking at, lower-cased.
surfaceWhere the card is shown. Always inbox_panel for now.
localeThe viewer's language, for example en-AU. en when it isn't known.
timezoneThe viewer's time zone, for example Australia/Brisbane. UTC when it isn't known.

Who the subject is. It is the other party: the sender of an email that came in, or the first recipient of one that went out. When it is someone in your organisation's own email domains, no app is asked anything.

Email first, then the domain. Commander asks about the address first. Only if you answer no_match does it ask again, in a second request with its own request_id and signature, about the address's domain. It never asks about the domain of a consumer mail provider such as gmail.com, because that says nothing about which customer wrote.

3. Verify the signature

Check every request before you read it:

  1. Read the raw request body as text. Do not parse and re-serialise it first.
  2. Compute HMAC-SHA256 over "context." + Waymaker-Timestamp + "." + rawBody. The key is the whole secret string, whsec_ included, as UTF-8.
  3. Compare v1=<hex> with Waymaker-Signature in constant time.
  4. Reject the request if the timestamp is more than 60 seconds from your clock.
  5. You can also reject a Waymaker-Context-Id you've already answered. Commander never sends the same request twice.

This is not the shared inbox events recipe. The context. prefix and the secret are both different. An app that does both keeps two secrets, and a signature made for one never verifies as the other.

// Returns the parsed request, or null when it is not a genuine, fresh Waymaker context request.
export async function verifyContextRequest(req: Request, secret: string) {
  const raw = await req.text()
  const ts = req.headers.get('Waymaker-Timestamp') ?? ''
  const sig = req.headers.get('Waymaker-Signature') ?? ''
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 60) return null
  const hex = sig.startsWith('v1=') ? sig.slice(3) : ''
  if (!/^[0-9a-f]{64}$/.test(hex)) return null
  const key = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
  )
  const mac = new Uint8Array(hex.match(/../g)!.map((h) => parseInt(h, 16)))
  // crypto.subtle.verify compares in constant time.
  const ok = await crypto.subtle.verify(
    'HMAC', key, mac, new TextEncoder().encode(`context.${ts}.${raw}`),
  )
  return ok ? JSON.parse(raw) : null
}

This runs anywhere Web Crypto does: Node 18 and later, Deno, Bun, and edge runtimes.

Test vector

Check your verifier against this before you go live. The body is one line, exactly as shown, with no trailing newline.

secret:    whsec_test_vector_not_a_real_secret
timestamp: 1767225600
body:      {"request_id":"7d1c2f0a-4b3e-4c55-9a61-2f8e0b9d3c47","app_id":"3f6b8a2e-1c4d-4e7f-8a9b-0c1d2e3f4a5b","org":"org_example","viewer":{"sub":"user_example","email":"sam@yourcompany.example"},"subject":{"kind":"email","value":"jane@customer.example"},"surface":"inbox_panel","locale":"en-AU","timezone":"Australia/Brisbane"}
signature: v1=94e797473f5308870b3741e5e3e87d6734d33696b56f28acd1c9de8faaf0c8c2

The timestamp is in the past, so turn off the 60-second check while you test against it.

4. Decide what this viewer may see

Look up your user by viewer.email. That user is already verified in your app. Then apply your app's own permissions to what you return. Never return more than this person could see by opening your app themselves. viewer.sub identifies the person to Waymaker; don't use it as your own user id.

  • When you hold the subject but this viewer may not see it, answer no_match, exactly as if you didn't hold it. Commander may then ask about the domain, so apply the same check to that request too. Never return a partial or redacted card: that alone says the subject is yours.
  • When several records match (two contacts with one address, or a parent and a child account), return one card: the record this viewer can see that matters most to them. Don't merge them.

A context request is a read. Never create, change or send anything because of one. The subject comes from an email, and anyone can send an email, so a stranger chooses who you're asked about.

5. Answer

Answer each request within 1.5 seconds, with a 2xx status and a JSON body of at most 16 KB. The email and domain requests each have their own 1.5 seconds.

When you don't know the subject:

{ "status": "no_match", "card_schema": 1 }

When you do:

{
  "status": "match",
  "card_schema": 1,
  "card": {
    "title": "Customer Co",
    "subtitle": "Enterprise account since 2024",
    "badge": "Key account",
    "fields": [
      { "label": "ARR", "value": "$1.2M", "kind": "money" },
      { "label": "Renewal", "value": "1 Mar 2027", "kind": "date" },
      { "label": "Account", "value": "A-1042", "kind": "ref" }
    ],
    "lists": [
      {
        "label": "Open cases",
        "items": [
          { "ref": "C-3381", "title": "Export fails on large reports", "status": "Waiting on us" },
          { "ref": "C-3374", "title": "SSO for the Sydney office", "status": "In progress" }
        ]
      }
    ],
    "actions": [
      { "label": "Open account", "kind": "open", "path": "/accounts/1042" },
      { "label": "Raise case", "kind": "open", "path": "/cases/new?account=1042" }
    ]
  }
}
PartRules
titleRequired. Up to 120 characters.
subtitleOptional. Up to 120 characters.
badgeOptional. Up to 40 characters.
fieldsRequired; may be empty. Up to 12. Each has a label (up to 60 characters), a value (a string, up to 200 characters) and a kind: text, money, date or ref.
listsOptional. Up to 3. Each has a label (up to 60 characters) and up to 10 items. An item has a ref (up to 60), a title (up to 160) and an optional status (up to 40).
actionsRequired; may be empty. Up to 4. Each is { "label", "kind": "open", "path" }: a label up to 40 characters, and a path up to 512.

Plain text only. Every string is shown exactly as you send it. Leading and trailing spaces are trimmed, and the rest is never treated as HTML or markdown. A whole card is dropped, not partly shown, when any string is empty, too long, holds a control character (line breaks and tabs included), holds an invisible or direction-changing character, or looks like markup (< followed by a letter, /, ! or ?). ARR > $1M is fine. A value that is a number, rather than a string, also drops the card. Format money and dates yourself, using locale and timezone.

Actions are paths on your app. A path starts with a single /, and holds no scheme, host, backslash or whitespace. Commander turns it into an address on your app's production address and opens it in a new tab, where the person signs in to your app as usual. An action that points anywhere else drops the card.

When there's no card. A slow answer, a status other than 2xx, a redirect (it is not followed), a body over 16 KB, a body that isn't JSON, a card_schema other than 1, or a card that breaks a rule above: each shows no card from your app. Commander never shows an error to the viewer, and the other apps' cards still show.

Commander asks at most three apps about each email, in name order, all at the same time.

What you learn, and what to keep

A request tells you that this person looked at this sender, at this time. Treat it like any access log: keep only what you need, and don't keep the subject longer than you need it.

Viewers can turn you off

Each viewer can turn a provider off for themselves in Commander. You then stop receiving their requests, not only their cards.

Rotating the secret

waymaker host apps context-provider rotate-secret <app-id> --confirm

From an agent, use host_app_context_provider_rotate. The new secret is shown once, and the old one stops signing immediately. Put the new secret in the app's environment and redeploy straight after. Until then your app shows no cards, because its signature checks fail.