# Sortd Integration Guide for Coding Assistants

_Guide version: 2026-10-10_

You are a coding assistant working inside a client application repository. Your task is to integrate Sortd support into this application with the smallest reliable change set.

Use this file as the implementation reference. Inspect the client app first, choose the correct integration mode with the developer, implement using the app's existing framework patterns, and verify the flow end to end.

## Inputs to Request From the Developer

Ask for these values before making changes:

- `SORTD_BASE_URL`: Sortd platform origin, for example `https://support.example.com`.
- `SORTD_API_KEY`: App-scoped Sortd API key, format `sk_app_...`.
- `SORTD_INGEST_KEY` (runtime error reporting only): a dedicated write-only key for `POST /api/errors`, format `sk_ingest_...`, created in the portal (**Settings → Alerts → Ingest keys**) and shown once. Preferred over `SORTD_API_KEY` for that endpoint; server-only like every other credential here.
- `SORTD_WEBHOOK_SECRET`: Secret used to verify Sortd webhook signatures. Required for API integration. Generated fresh whenever `webhookUrl` is set (portal, or `PATCH /api/portal/apps/:appId`) and returned once in that response.
- `SORTD_WEBHOOK_PATH`: The path in this app that should receive Sortd callbacks, for example `/api/webhooks/sortd`.
- End-user identity fields available in the app: stable user ID, email, display name, and optional tenant or workspace ID.
- For Embed integration: at least one entry in `allowedOrigins` (portal Settings, or `PATCH /api/portal/apps/:appId` with `{ allowedOrigins: string[] }`) — the embed will not load on **any** domain until one is configured.
- For MCP integration: the `SORTD_TOKEN` value copied from the Sortd portal (My Apps → [App] → MCP Server → Generate token). Sortd generates this token — the developer does not need to create one. They copy it once from the portal and add it to their environment.

Never expose `SORTD_API_KEY` in browser code, mobile app bundles, public config, logs, source maps, or client-readable environment variables.

## First Pass Repository Analysis

Before editing code:

1. Identify the app framework and routing system.
2. Locate existing authenticated user/session helpers.
3. Locate environment variable conventions and validation.
4. Locate existing API route/server action/controller patterns.
5. Locate existing webhook handlers or raw-body parsing utilities.
6. Locate existing support, chat, help, or command UI surfaces.
7. Locate the app's test framework and add focused tests around the integration points you change.

Prefer existing app-native patterns. Do not introduce a new HTTP client, validation library, state library, or UI framework unless the app already uses it.

## Choose the Integration Mode

Sortd's API is built around long-lived **conversations** between an end user and the Sortd agent. The agent answers most questions inline. It only escalates into a **ticket** when it decides human follow-up is needed (low confidence, bug detected, sensitive topic, user requested human, and so on). Tickets are an escalation hand-off card, not the unit of every Q&A turn.

Use Embed integration when:

- The client app does not already have a support or chat UI.
- The team wants the fastest hosted support experience.
- The app can load an iframe or script from `SORTD_BASE_URL`.

Use API integration when:

- The client app already has its own support UI, chat UI, command palette, or help surface.
- The team wants Sortd agent replies to appear inside native app UI.
- The app can run server-side code to call Sortd and verify webhooks.

Use **Own-Agent integration** (a variant of API integration — see "Integrate With Your Own AI Agent") when:

- The client app already has its own AI agent or assistant.
- The team does not want two chat interfaces competing in one product.
- Sortd should be a tool that agent calls, with the app's agent still owning the conversation and the UI.

If both are useful, implement API integration for the native chat experience and Embed integration as a secondary help widget.

## Environment Variables

Add server-only environment variables using the app's existing config convention:

```env
SORTD_BASE_URL=https://support.example.com
SORTD_API_KEY=sk_app_replace_me
SORTD_WEBHOOK_SECRET=replace_me
SORTD_WEBHOOK_PATH=/api/webhooks/sortd
```

If the app distinguishes public and server env vars, only `SORTD_BASE_URL` may be public. `SORTD_API_KEY` and `SORTD_WEBHOOK_SECRET` must remain server-only.

## Embed Integration

Embed integration gives the client app a hosted Sortd support UI.

### Allowed Origins (Required)

The embed will not load on **any** domain until at least one origin is configured. Set `allowedOrigins` on the app (portal → Settings, or `PATCH /api/portal/apps/:appId` with `{ "allowedOrigins": ["https://app.example.com"] }`) before testing the iframe or script loader below — a missing entry here is the most common reason a freshly-wired embed renders blank.

### Session-Based Embed

Flow summary: the browser asks the client app backend for an embed session; the client app backend calls Sortd with the server-only `SORTD_API_KEY`; Sortd verifies the key and returns an opaque `sessionId` for the current end user; the browser renders the iframe with that session id in the `sessionId` query parameter. The browser must never call Sortd with `SORTD_API_KEY` directly. Sessions last 1 day (`expiresAt`).

Create a server endpoint in the client app that mints an embed session for the currently signed-in user:

```ts
async function createSortdEmbedSession(user: {
  id: string;
  email: string;
  name?: string;
  role?: 'user' | 'admin' | 'owner';
  tenantId?: string;
  tenantName?: string;
}) {
  const res = await fetch(`${process.env.SORTD_BASE_URL}/api/embed/sessions`, {
    method: 'POST',
    headers: {
      authorization: `Bearer ${process.env.SORTD_API_KEY}`,
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      externalUserId: user.id,
      email: user.email,
      name: user.name,
      role: user.role,
      appTenantId: user.tenantId,
      appTenantName: user.tenantName,
    }),
  });

  if (!res.ok) {
    throw new Error(`Sortd embed session failed: ${res.status}`);
  }

  return res.json() as Promise<{
    sessionId: string;
    expiresAt: string;
  }>;
}
```

`role` controls what the session can see and act on:

- `user` (default): sees and can act on only their own tickets.
- `admin`: sees and can act on tickets within its own `appTenantId` (plus any tickets created before tenant scoping was in use, which have no tenant recorded). Omit `appTenantId` on an `admin` session and it sees every ticket in the app instead — tenant scoping only activates once you start passing `appTenantId`.
- `owner`: always sees and can act on every ticket in the app, across every tenant, regardless of its own `appTenantId`. Use this for a product owner, developer, or manager who needs the full picture.

Pass `appTenantName` alongside `appTenantId` to give that tenant a human-readable label — it has no effect on ticket visibility, purely display. Sortd remembers the latest name seen for each `appTenantId` and uses it to tag tickets and populate a tenant filter in the Sortd dashboard, instead of showing the raw id. Optional; omit it and tickets just show the raw `appTenantId`.

Cache `{ sessionId, expiresAt }` per user in the app's database and reuse it until ~5 minutes before `expiresAt`. Avoid minting a new session on every page load.

#### Internal (your own team) end users

`role` is set by your backend at session-mint time and is only ever trusted up to `admin`/`owner` — it is not the mechanism for marking someone as part of *your own* dev/support team rather than a real customer. That distinction is a separate, portal-only flag: in the Sortd portal's **Users** tab (per app), a client admin can flag a specific end user as **Internal**. An internal end user's embed session then gets:

- Unrestricted ticket visibility across every tenant of the app — the same reach as `role: 'owner'`, regardless of what `role`/`appTenantId` your backend minted the session with.
- The ability to create **Actions** (`issueType: 'action'` on `POST /api/embed/tickets` — work assigned *to* one or more of your end users, e.g. "create your Apple developer account and send us the credentials"). This is forbidden for every other session.

There is a separate **Owner** toggle in the same Users tab that grants `owner`-equivalent ticket visibility without the Actions ability — use Internal for your own staff, Owner for a non-staff person (e.g. a client-side product owner) who just needs the full cross-tenant picture.

Neither flag is set via the session-mint API — they're portal-only, per end user, and take effect immediately on that person's next request (no need to re-mint their session).

Render the iframe with the returned session id:

```html
<iframe
  src="https://support.example.com/embed?sessionId=ess_...&views=chat,tickets&initial=chat"
  title="Sortd Support"
  style="width:100%;height:600px;border:0;"
  allow="clipboard-write; display-capture; microphone; fullscreen"
  allowfullscreen
></iframe>
```

Replace `https://support.example.com` with `SORTD_BASE_URL` and URL-encode the session id.

### Browser Permissions (Required for Voice, Screen Recording, and Full-Screen Media)

The widget can dictate a message from the microphone, record the user's screen to attach to a bug report, and play back an attached image or video full-screen. All are policy-controlled browser capabilities, which a cross-origin iframe does not get by default — the host page has to delegate them explicitly. Set the full list (and the legacy `allowfullscreen` boolean attribute, still required by some browsers) on every iframe that renders the embed:

```html
allow="clipboard-write; display-capture; microphone; fullscreen"
allowfullscreen
```

| Capability | What it powers | Symptom when missing |
|------------|----------------|----------------------|
| `clipboard-write` | Copy button on agent answers and code blocks | The button does nothing, with no error shown. |
| `display-capture` | In-widget screen recording of a bug | Recording fails the instant it is clicked — no OS share picker ever appears. |
| `microphone` | Voice input, and narration over a screen recording | Reports "Microphone access blocked", even when the user already granted mic permission to the site. |
| `fullscreen` (+ `allowfullscreen` attribute) | Full-screen playback of a video attached to a ticket | The player's full-screen control renders disabled/greyed out — the Fullscreen API is blocked by the iframe's permissions policy, not a video bug. |

The buttons are **not** hidden when a capability is missing — the underlying browser APIs still exist, so the failure happens at click time and reads like a user permission problem rather than a wiring problem. Each capability is independent: omitting one degrades that feature only, and chat, tickets, and file attachments work regardless.

**`fullscreen` was added 2026-08-12.** If your iframe's `allow` list predates that, re-copy the snippet above — otherwise the full-screen control on video and image attachments renders present but permanently disabled.

Two things the `allow` attribute cannot fix on its own:

- **The host page must hold the capability itself.** `allow` delegates; it cannot grant what the page does not have. If the client app sends its own `Permissions-Policy` response header, that header must also name the Sortd origin, or the delegation is dropped and the `allow` attribute has no effect:
  ```
  Permissions-Policy: display-capture=(self "https://support.example.com"), microphone=(self "https://support.example.com"), clipboard-write=(self "https://support.example.com"), fullscreen=(self "https://support.example.com")
  ```
  An app that sends no `Permissions-Policy` header at all needs no change — the browser default already permits delegation via `allow`.
- **The page must be a secure context** — `https://` or `localhost`. Over plain `http://` the capture APIs do not exist at all, and the mic and record buttons hide themselves entirely.

The script loader (`embed.js`) sets this `allow` list on the iframe it creates, so loader integrations need no change here. This section applies to the raw iframe above and to any custom component that mounts the embed itself.

### Voice Mode

Voice Mode lets a user talk to the assistant in the main chat lane instead of typing — real-time voice-to-voice, not just dictation. It needs no integration work beyond the microphone permission already covered above: if your iframe's `allow` list includes `microphone`, the voice entry point just works once enabled.

It is **opt-in per app** and off by default. Turn it on in the Sortd portal under **App Settings → Voice Mode**. A toggle flipped there takes effect for open embed sessions within about a minute — it is not instant, because the setting is resolved from a short-lived session cache, not read on every message.

App-specific questions asked by voice are answered by the same grounded engine as the chat composer — the voice model never answers from its own knowledge, so voice and text give consistent, equally-cited answers. There is no separate API surface for this: everything happens inside the existing embed session your app already mints.

Every other typed field in the widget (ticket replies, raise-issue descriptions, fix verification, ratings, message edits) supports **dictation** — press-and-hold-free voice-to-text — rather than a live voice conversation. Same microphone permission, no separate integration step.

### Session Lifecycle (all backend-only, Bearer app key)

All session management calls require `Authorization: Bearer <SORTD_API_KEY>`. None of these are called from the browser.

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/embed/sessions` | Mint a new session. Body: `{ externalUserId, email, name?, role?, appTenantId?, appTenantName? }`. `role` is `user` (default), `admin`, or `owner` — see above for what each grants. `appTenantName` is a display-only label for `appTenantId` (see above). Returns `{ sessionId, expiresAt }`. Sessions last 1 day. |
| `PUT` | `/api/embed/sessions/:id/refresh` | Extend expiry. Returns `{ sessionId, expiresAt }`. Call when the cached session is within ~5 min of expiry. |
| `DELETE` | `/api/embed/sessions/:id` | Revoke immediately (call on sign-out). Returns `200 { ok: true }`. |
| `GET` | `/api/embed/sessions/:id` | Check validity. Returns `{ active, expiresAt }`. |

### Script Loader Option

`embed.js` renders a floating support widget instead of an inline iframe. It still requires a session id minted server-side via `POST /api/embed/sessions` — it does not accept an API key. Inject the session id into the page server-side (e.g. a template variable), then set it as `data-session-id`:

```html
<script
  src="https://support.example.com/embed.js"
  data-session-id="ess_SESSION_ID_FROM_SERVER"
  data-base-url="https://support.example.com"
  data-position="bottom-right"
  data-open-on-load="false"
  defer
></script>
```

Panel sizing: the floating panel keeps one consistent size across all views — 440×700 by default, always clamped to the visitor's viewport — so do not set a size unless the design requires it. To pin a different size, pass `data-width` and/or `data-height` (px numbers, e.g. `data-width="480"`); pinned values are still clamped to the viewport.

Tabs: the widget has four — `home`, `chat`, `tickets`, `help`. **All four are shown by default**; do not set `data-views` unless you want to hide some. To restrict, pass a comma list (e.g. `data-views="chat,tickets"`); `data-initial` picks the tab that opens first (omit it to use the widget's own default).

Expand / Close: inside the floating panel the widget shows an **Expand** button (widens the panel to `min(960px, viewport − margins)`, and back) and a **Close** button. The expanded state is remembered across open/close and page loads (`localStorage` key `sortd_expanded`). On narrow viewports (under 640px) the panel is already full-bleed, so Expand does nothing. If you pin the width with `data-width`, Expand is not offered (you own the width) but Close still is. These buttons appear only when the loader sends the `sortd:host` handshake with `canResize: true` — a raw inline iframe never receives it, so it shows no Expand/Close buttons and the host owns the size (see the message contract below).

Launcher greeting: a small dismissible greeting card appears above the launcher bubble once per browser session while the panel is closed (dismissal is remembered in `localStorage` key `sortd_greeting_dismissed`) and is hidden when the panel opens. Set `data-greeting="off"` to never show it.

`data-session-id` is required — the script logs an error and does nothing without it. Never put `SORTD_API_KEY` in this or any browser-served HTML; only the opaque `sessionId` belongs on the page.

### Minimize Must Not Unmount (Custom Launchers)

If the client app builds its own launcher around the raw iframe — a button that opens a docked panel — then minimizing has to **visually collapse** the panel, never remove it from the DOM. `{open && <Panel />}` is the wrong shape here, and it is the default one most implementations reach for first.

Unmounting the iframe tears down the widget's whole runtime: an in-flight streamed reply is lost, an in-progress screen recording is destroyed mid-capture, the composer draft and scroll position are cleared, and reopening pays a full iframe boot before the user can type again. The conversation history itself is safe — the embed rehydrates it server-side — so this is a UX cost, not data loss, but it is the difference between a support panel the user parks and one they avoid reopening.

The state model that gets this right separates three things that are easy to conflate:

- `mounted` — has the panel ever been opened? A one-way latch: `false` → `true` on first open, and nothing sets it back.
- `open` — does the user want the panel showing? This is what minimize toggles.
- `visible` — should it be painted right now? Derived: `open && !somethingElseIsCoveringIt`. Anything that hides the panel for layout reasons (a right-docked drawer, a modal) goes through this and must never touch `open` or `mounted`.

```tsx
const [mounted, setMounted] = useState(false);
const [open, setOpen] = useState(false);
const visible = open && !drawerOpen; // a right-docked drawer would overlap the panel

const openPanel = () => { setMounted(true); setOpen(true); };
const minimize = () => setOpen(false); // NOT setMounted(false)

// Mint the session on first open only — reopening must never re-mint.
useEffect(() => {
  if (mounted && !session) ensureSession();
}, [mounted, session]);

// Once mounted, the same DOM node and the same iframe render on every pass.
return mounted ? (
  <div
    style={{
      width: visible ? PANEL_WIDTH : 0,
      visibility: visible ? 'visible' : 'hidden',
      overflow: 'hidden',
      transition: 'width 220ms ease, visibility 0s linear',
      // Closing: slide shut first, hide after. Opening: show immediately.
      transitionDelay: visible ? '0s, 0s' : '0s, 220ms',
    }}
  >
    <iframe
      src={embedSrc}
      title="Sortd Support"
      allow="clipboard-write; display-capture; microphone; fullscreen"
      allowFullScreen
      style={{ width: PANEL_WIDTH, height: '100%', border: 0 }}
    />
  </div>
) : null;
```

Rules that keep this working:

- **`src` must be stable across renders.** Recomputing it — a re-minted session id, a cache-busting timestamp — changes the attribute and reloads the iframe, which costs exactly what unmounting costs. Compute it once when the session resolves and hold it.
- **Give the iframe a fixed pixel width**, not `100%`. Animating the wrapper to `0` would otherwise reflow the widget's own layout on every frame of the collapse.
- **The only legitimate teardown** is discovering the app cannot be embedded at all — no session can ever be minted for this user (missing config, revoked key). That is a real unmount, not a minimize.
- **Escape and the close button both map to `minimize`**, not to a teardown.

The script loader does all of this already: it builds the iframe once, then toggles opacity, transform, and `pointer-events` on the wrapper. This section only applies to a hand-built launcher.

### Theming — Match the Widget to the Host App

The widget defaults to Sortd's dark theme. To make it look native, read the host app's design tokens (Tailwind config, CSS variables, or theme file) and pass the matching values. Only the values you set are overridden; the rest keep their defaults.

Theme options (all optional):

| Option | Loader attribute | Iframe query param | Value |
|--------|------------------|--------------------|-------|
| Color mode | `data-theme` | `theme` | `dark` \| `light` \| `auto`. Defaults to `light` when omitted; `auto` follows the visitor's OS preference. |
| Brand accent | `data-accent` | `accent` | Hex color (`#rgb` / `#rrggbb`). Buttons, links, focus rings, gradients, and the launcher bubble derive from it. |
| On-accent text | `data-accent-foreground` | `accentForeground` | Hex. Auto-picked by contrast when omitted. |
| Page background | `data-background` | `background` | Hex. |
| Card/header background | `data-surface` | `surface` | Hex. Hover/elevated shades derived. |
| Primary text | `data-text` | `text` | Hex. Secondary/muted text and borders derived. |
| Corner radius | `data-radius` | `radius` | Base radius in px, 0–32. Full radius scale derived. |
| Font | `data-font` | `font` | Font-family stack, e.g. `Inter, sans-serif`. |

Rules:

- Colors must be hex — named colors, `rgb()`, gradients, and CSS variables are rejected. Resolve the host app's tokens to hex before passing them.
- Invalid values are silently ignored (the widget falls back to its default theme) — verify visually after wiring.
- Match the host app's mode: pass `data-theme="dark"` for dark-mode apps (light is the default). If the host app has a light/dark toggle, call `window.SortdSupport.setTheme({...})` when it changes — the widget cannot see the host app's theme by itself.

```html
<script
  src="https://support.example.com/embed.js"
  data-session-id="ess_SESSION_ID_FROM_SERVER"
  data-theme="light"
  data-accent="#6366f1"
  data-background="#ffffff"
  data-surface="#f8fafc"
  data-text="#0f172a"
  data-radius="10"
  data-font="Inter, sans-serif"
  defer
></script>
```

For the raw iframe, pass the same values as query parameters (URL-encode `#` as `%23`):

```
/embed?sessionId=ess_...&theme=light&accent=%236366f1&background=%23ffffff&radius=10
```

Runtime switching (loader only — retints the launcher bubble and panel chrome as well as the iframe):

```js
// e.g. inside the host app's theme-toggle handler
window.SortdSupport.setTheme({ mode: 'dark', accent: '#818cf8', background: '#0f172a' });
```

A minimal integration usually only needs `data-theme` + `data-accent`; derive the rest only when the host app's background/typography visibly clash.

### Deep-Link Navigation (Optional, Script Loader Only)

When the agent's answer points a user at a screen in the client app (e.g. "Settings → Billing"), the widget can navigate the host app directly instead of just naming the screen. This requires an analyzed repo with a populated navigation map — a tenant with none configured sees no change in behavior.

By default, clicking a link does a same-origin full-page navigation and the widget reopens itself once the new page loads (the conversation is unaffected — it rehydrates server-side). For an instant transition with no reload, wire the widget to the host app's own router:

```js
window.SortdSupport.onNavigate(({ path, label }) => {
  router.push(path); // e.g. Next.js/React Router — whatever the host app already uses
});
```

`path` is always a relative, same-origin path (e.g. `/settings/billing`) — never an absolute URL. The widget itself is never closed or reloaded by this: only the host page navigates. This is optional but recommended for single-page apps, where a full-page reload is a worse experience than an instant router transition.

```js
// Optional — observe every navigation for analytics, whether or not onNavigate is wired
window.SortdSupport.on('navigate', ({ path, label }) => { ... });
```

Not available on the raw iframe integration — it has no host-page JavaScript to call into, so links render as plain text there instead.

### The iframe Message Contract (Raw iframe Only)

Integrations that embed the iframe directly, rather than via `embed.js`, talk to it with `postMessage`. The script loader wraps all of this in `window.SortdSupport`, so this section only matters when driving the iframe yourself.

Messages the widget **posts to the host** carry `source: 'sortd-embed'`:

| `type` | Payload | Meaning |
|---|---|---|
| `ready` | — | The widget's listener is live. Wait for this before sending anything; a message sent earlier is dropped. |
| `sortd:resize` | `{ width, height }` | The widget's preferred panel size. |
| `sortd:navigate` | `{ path, label }` | The user clicked a deep link. `path` is always relative and same-origin. Route it with the host's own router, or ignore it and links stay inert. |
| `sortd:expand` | — | The user pressed Expand. Widen your panel (the loader uses `min(960px, viewport − margins)`). Only sent after you advertised `canResize: true`. |
| `sortd:collapse` | — | The user pressed Collapse. Restore the size last requested via `sortd:resize`. |
| `sortd:close` | — | The user pressed Close. Hide your panel (hide, never unmount — see above). |

Messages the host **sends to the widget** must carry `source: 'sortd-host'` and be posted from the window that owns the iframe — the widget checks the source window, and messages from anywhere else are ignored:

| `type` | Payload | Effect |
|---|---|---|
| `sortd:host` | `{ canNavigate: boolean, canResize?: boolean, canExpand?: boolean, expanded?: boolean }` | The handshake. Send it once on `ready`, and again whenever `expanded` changes so the widget's Expand/Collapse icon flips. Until it arrives with `canNavigate: true` the widget renders no navigation affordances, so a raw iframe that never sends it simply shows deep links as plain text. Without `canResize: true` the widget renders no Expand and no Close button — the host owns the size, which is the default for a raw inline iframe. `canExpand: false` (with `canResize: true`) hides only Expand — what the loader sends when you pinned `data-width` or the viewport is under 640px (and it re-sends the handshake when that breakpoint is crossed). |
| `open:ticket` | `{ ticketId }` | Opens that ticket's detail view inside the widget. |
| `open:raise-issue` | — | Opens the raise-an-issue form. |
| `theme:set` | `{ theme }` | Merges theme overrides over the initial query-param theme. |

```js
const frame = document.getElementById('sortd').contentWindow;

window.addEventListener('message', (ev) => {
  if (ev.source !== frame || ev.data?.source !== 'sortd-embed') return;
  if (ev.data.type === 'ready') {
    frame.postMessage({ source: 'sortd-host', type: 'sortd:host', canNavigate: true }, '*');
  }
  if (ev.data.type === 'sortd:navigate') router.push(ev.data.path);
});

// Deep-link into a ticket from elsewhere in the host app:
frame.postMessage({ source: 'sortd-host', type: 'open:ticket', ticketId }, '*');
```

### Which Embed Endpoints Are Supported

The endpoints documented in this guide — session lifecycle, `POST /api/embed/tickets`, and the SSE stream — are a stable, supported surface.

Everything else under `/api/embed/` is **widget-internal**: those routes exist to serve this widget, are authenticated with a browser session rather than an API key, and may change shape in any release without notice. Do not call them from a server, and do not mint an embed session in order to reach them. If a need seems to require one, there is an app-key route for it:

| The need | Use this, not an embed route |
|---|---|
| Start a fresh conversation thread | `clientThreadId` on `POST /api/agent/ask` or `POST /api/conversations` |
| Check whether a thread is still writable | Nothing needed — threads keyed by `clientThreadId` are not archived by opening another |
| Read a conversation and its messages | `GET /api/conversations/:id` |
| List an end user's conversations | `GET /api/conversations?endUserExternalId=…` |
| Read one ticket | `GET /api/tickets/:ticketId` — accepts the app key |
| Track an end user's tickets | There is no app-key list route today. Collect ids from `ticket.created` webhooks, from `ticket.id` on an `escalated` ask outcome, or from `ticketIds` on the conversation, then read each by id. Tell us if that is not enough — do not reach for an embed session instead. |

### Embed Verification

Verify:

- `allowedOrigins` includes the domain the widget is served from — a missing entry is the most common cause of a blank embed.
- The embed loads for a signed-in user.
- The browser never receives `SORTD_API_KEY` — only the opaque `sessionId` is passed to the iframe.
- A user can send a message and the agent reply appears.
- Click the mic button and the record-screen button. The mic prompts for permission and dictates; the record button opens the OS share picker. Either failing on click means the `allow` list or the host app's `Permissions-Policy` is missing that capability — not a user permission problem.
- If a custom launcher was built: send a message, minimize, reopen. The conversation is still on screen with no loading state and no reload — anything else means the iframe is being unmounted or its `src` recomputed.
- Sign-out or user switching revokes the session (`DELETE /api/embed/sessions/:id`) so the iframe cannot be reused.
- If theming was configured: the widget's accent/background/mode visually match the host app, and (loader only) the launcher bubble is retinted. A widget still showing the default amber-on-dark theme means a theme value was invalid and silently dropped — check hex formatting.
- If `onNavigate` was wired: clicking a deep link in an agent answer calls it with a relative path and the widget stays open, with no page reload.

## API Integration

API integration lets the client app drive Sortd from its own UI: open a conversation per end user, append messages, stream the agent reply, and receive a webhook when the agent escalates a conversation into a ticket.

### Mental Model

> **If the client app has its own AI agent, use `POST /api/agent/ask` instead** (see *Integrate With Your Own AI Agent*). It does all four steps below in one call and returns a typed outcome, so there is no poll loop to hand-write. The endpoints in this section are the lower-level surface, and are what to use when Sortd's replies are rendered in a chat UI of the client's own rather than consumed by an agent.

1. Open or reuse a `Conversation` per end-user chat session. One live conversation per end user by default; pass `clientThreadId` to keep one per chat instead.
2. Append each user message to the conversation, with any files the user attached. The agent processes asynchronously.
3. Render the agent reply by either subscribing to the SSE stream (browser) or polling `GET /api/conversations/:id` (server).
4. If the agent decides the conversation needs human follow-up, Sortd POSTs a `ticket.created` webhook to `SORTD_WEBHOOK_PATH`. Sortd also sends `ticket.updated`, `ticket.resolved`, and `conversation.resolved` webhooks as the ticket/conversation lifecycle progresses — see below. Most Q&A turns never trigger a webhook at all; only escalations and their follow-on lifecycle changes are webhook-worthy.

### Open a Conversation

```http
POST https://support.example.com/api/conversations
Authorization: Bearer {SORTD_API_KEY}
Content-Type: application/json

{
  "endUser": {
    "externalId": "user_123",
    "email": "jane@acme.com",
    "name": "Jane Doe",
    "anonymousId": "anon_abc"
  },
  "initialMessage": "How do I export?"
}
```

`endUser.externalId` should be the client app's stable user ID. Use `anonymousId` only when no `externalId` exists — one of the two is required. `email`, `name`, `clientThreadId`, and `initialMessage` are optional.

`clientThreadId` is the client's own id for the chat this conversation belongs to. Sortd keeps one live conversation per distinct value, so an app whose users can have several chats open gets one support thread per chat instead of one per user. Omit it and the historic behaviour applies: one live conversation per end user.

`201 Created` when a conversation was opened, or `200 OK` carrying the end user's existing active conversation:

```json
{
  "conversation": {
    "_id": "6650...",
    "appId": "6640...",
    "endUser": { "externalId": "user_123", "email": "jane@acme.com" },
    "state": "active",
    "messages": [
      { "role": "user", "content": "How do I export?", "createdAt": "..." }
    ]
  }
}
```

This endpoint is **open-or-reuse**, so it is safe to call on every chat open: a second call with the same identity returns that same conversation rather than opening a duplicate. Only a call that actually opens one counts against the conversation quota.

"Same identity" means the same `endUser` **and** the same `clientThreadId`. Without a thread id an end user has at most one active conversation, and opening a new one archives the previous — so to run several live threads for one user, give each its own `clientThreadId` rather than reopening. Threads are independent: they never archive one another, and each keeps its own transcript. Each distinct thread counts as one conversation against the plan, however many messages it carries.

Reuse the returned `_id` for the rest of that end-user's chat session.

Errors: `400` when neither `externalId` nor `anonymousId` is supplied; `401` on a bad/missing key; `402 { "code": "quota_exceeded" }` when the org is over its monthly conversation quota; `409 { "code": "TENANT_CONFLICT" }` on an unresolvable collision (rare — a concurrent open whose winner this caller's own scoping cannot see).

Responses never include Sortd's internal agent state (the rolling context summary, planner counters, agent-run telemetry, or an escalation's internal justification). Fields beyond those documented here should be treated as absent rather than depended on.

Server-side helper:

```ts
type SortdEndUser = {
  externalId?: string;
  anonymousId?: string;
  email?: string;
  name?: string;
};

export async function openSortdConversation(input: {
  endUser: SortdEndUser;
  /** This chat's id — one live Sortd conversation per value. */
  clientThreadId?: string;
  initialMessage?: string;
}) {
  const res = await fetch(`${process.env.SORTD_BASE_URL}/api/conversations`, {
    method: 'POST',
    headers: {
      authorization: `Bearer ${process.env.SORTD_API_KEY}`,
      'content-type': 'application/json',
    },
    body: JSON.stringify(input),
  });

  if (!res.ok) {
    throw new Error(`Sortd open conversation failed: ${res.status} ${await res.text()}`);
  }

  return res.json() as Promise<{ conversation: { _id: string; state: string; messages: unknown[] } }>;
}
```

### Append a Message

Appending a message is what triggers the agent.

```http
POST https://support.example.com/api/conversations/:id/messages
Authorization: Bearer {SORTD_API_KEY}
Content-Type: application/json

{
  "content": "Why does my export come out empty?",
  "attachments": [
    { "type": "screenshot", "url": "https://.../uploads/abc.png", "mimeType": "image/png", "filename": "empty-export.png", "sizeBytes": 45210 }
  ]
}
```

`202 Accepted`:

```json
{ "messageId": "6651..." }
```

The agent runs asynchronously and appends its reply to the conversation. Retrieve it via the SSE stream or by reading the conversation — most Q&A turns never trigger a webhook, so do not assume the reply "arrives via webhook."

`attachments` is optional and takes the same shape as on `POST /api/tickets`: upload via `POST /api/upload` first, then pass the object it returns. `type` is a strict enum (`screenshot` | `file` | `video`) and an unrecognised value is a `400` naming the offending index — it is never accepted and dropped.

Content longer than 5000 characters is silently truncated server-side, not rejected — truncate client-side if you need the user to see that limit. Returns `409` if the conversation is `archived`, and a separate `409 conversation_limit_reached` once a conversation hits 500 messages — handle both by prompting the user to start a new conversation. Returns `429` when rate limited — the body's `scope` field says which ceiling tripped: `conversation` (default 60/min per conversation, `MSG_RATE_PER_MIN`) or `app` (default 600/min tenant-wide, `MSG_APP_RATE_PER_MIN`, checked first).

The `202` response also carries an optional `warnings: string[]` — present only when an attachment was accepted but will not do what you probably expect. Today the only case is a `video` attachment with no client-sampled frames and no transcript (see *Integrate With Your Own AI Agent → Attachments* below for the full explanation and how to supply either one). Log `warnings` during rollout — it is the signal an attachment silently became a no-op.

```ts
export async function sendSortdMessage(
  conversationId: string,
  content: string,
  attachments?: Array<{
    type: 'screenshot' | 'file' | 'video';
    url: string; mimeType: string; filename: string; sizeBytes: number;
  }>,
) {
  const res = await fetch(
    `${process.env.SORTD_BASE_URL}/api/conversations/${conversationId}/messages`,
    {
      method: 'POST',
      headers: {
        authorization: `Bearer ${process.env.SORTD_API_KEY}`,
        'content-type': 'application/json',
      },
      body: JSON.stringify({ content, attachments }),
    },
  );

  if (!res.ok) {
    throw new Error(`Sortd append message failed: ${res.status} ${await res.text()}`);
  }

  return res.json() as Promise<{ messageId: string }>;
}
```

### List Conversations

```http
GET https://support.example.com/api/conversations?endUserExternalId=user_123&limit=50&skip=0
Authorization: Bearer {SORTD_API_KEY}
```

Returns `{ "items": [ ...conversations ] }`. Use this to resume an existing chat session for a returning user.

### Read a Single Conversation

```http
GET https://support.example.com/api/conversations/:id
Authorization: Bearer {SORTD_API_KEY}
```

Returns the conversation with its full `messages` array plus `ticketIds[]` (any tickets this conversation has escalated into). Poll this from server code if you do not want to use the SSE stream.

### Rendering an Agent Answer

**An agent message's `content` is not always the whole answer.** When the agent gives a procedure, it returns three fields, and the steps are deliberately *not* repeated in the prose:

| Field | Always present | What it is |
|---|---|---|
| `content` | yes | The prose. A short summary on its own whenever `steps` is present. |
| `headline` | no | A one-sentence lead, shown above the rest. |
| `steps` | no | An ordered list of plain-text actions — the procedure itself. |
| `answerText` | only when `headline` or `steps` is present | All three composed into one markdown string, in display order. |

Two correct ways to render it, and one wrong one:

- **Simplest — use `answerText` when it is there.** `const body = message.answerText ?? message.content`. One line, never loses anything.
- **Richer — render the parts yourself:** `headline` as a lead line, `steps` as a numbered list, then `content`. Use this if you want the steps styled as a real list.
- **Wrong — rendering `content` alone.** A thirteen-step walkthrough silently becomes one sentence, and nothing about the response tells you the rest existed.

The same rule applies to the SSE `message` event and to ticket-thread messages.

### SSE Stream (Browser)

For embed-style flows where the browser shows the agent reply as it lands, subscribe to the per-conversation stream:

```
GET https://support.example.com/api/embed/conversations/:id/stream?token=<SESSION_ID>
```

Events emitted:

- `replay` — sent immediately on connect, carrying `{ conversation }` so a client that subscribes late still gets current state.
- `conversation:message` — a new message (user or agent) was appended.
- `ticket:created` — the agent escalated this conversation into a ticket.

`EventSource` cannot send custom headers, so the session id (minted by `POST /api/embed/sessions`) is passed as the `token` query param.

For server-side integrations, prefer the webhook below over the SSE stream.

### Webhooks

Sortd POSTs to `SORTD_WEBHOOK_PATH` for these events: `ticket.created`, `ticket.updated`, `ticket.resolved`, `conversation.resolved`, `bug_task.created`, `bug_task.ready_for_review`, and — only on apps with QA sign-off enabled — `bug_task.qa_requested`, `bug_task.qa_passed` and `bug_task.qa_failed`. Every `bug_task.*` event is also sent under a new name, `backlog_item.*` (see "Events: `backlog_item.*`" below). Escalation `reason` (on `ticket.created`) is one of: `low_confidence`, `no_knowledge`, `bug`, `feature_request`, `manual_action_required`, `sensitive_topic`, `negative_feedback`, `user_requested`, `agent_error`, `llm_budget_exceeded`, `input_rejected`, `grounded_answer_unsafe`, `user_raised`.

Setting `webhookUrl` (portal, or `PATCH /api/portal/apps/:appId`) generates a fresh `webhookSecret`, returned once in that response and thereafter visible on `GET /api/portal/apps/:appId`.

#### Event: `ticket.created`

Sent when the agent escalates a conversation into a ticket for human follow-up.

```json
{
  "event": "ticket.created",
  "timestamp": "2026-05-12T12:00:00Z",
  "ticket": {
    "id": "6650...",
    "displayId": "ACME-0142",
    "reason": "low_confidence",
    "intent": "QA",
    "summary": "User cannot find CSV export — likely missing permission.",
    "priority": "medium",
    "description": "I can't find the CSV export on the invoices page. Is it behind a permission?",
    "attachments": [
      { "type": "screenshot", "url": "https://files.example.com/screenshots/0b1c.png", "mimeType": "image/png", "filename": "invoices.png", "sizeBytes": 48211 }
    ],
    "reportedFrom": { "url": "/billing/invoices/8f21", "label": "Invoice detail" }
  },
  "reporter": { "email": "sam@acme.com", "name": "Sam Lee", "externalId": "user_123" },
  "conversation": {
    "id": "6651...",
    "summary": "User asked how to export to CSV; agent's KB lookup returned low-confidence results.",
    "lastMessages": [
      { "role": "user", "content": "How do I export to CSV?" },
      { "role": "agent", "content": "I'm not sure — let me hand this to the team." }
    ]
  },
  "source": { "kind": "embed-report" }
}
```

`ticket.priority` is the ticket's priority (`low`, `medium`, `high` or `critical`). `ticket.description` is the reporter's original message in full, whereas `ticket.summary` is a short title. `ticket.attachments` lists up to 10 files the reporter attached to that message (`type` is `screenshot`, `file` or `video`); it is absent when there are none. Attachment URLs are public and do not expire, so treat them as unlisted links rather than private files.

`reporter` is the end user who raised the ticket. It carries `email`, plus `name` and `externalId` when known, and is absent entirely for tickets with no end-user reporter (for example, tickets your own staff create in the portal).

`source` is present only for two origins: `{ "kind": "embed-report" }` when an end user submits the embed's standalone report form, and `{ "kind": "import", "importId": "..." }` for a bulk import.

`ticket.reportedFrom` is present only when the conversation (or the direct-raise request) carried a location — see *Raise a Ticket Directly* above. It carries the same `url`/`label` you sent, plus a server-derived `key` used for grouping (e.g. `/billing/invoices/:id`) — group on `key`, not `url`, if you're aggregating by area on your own side.

#### Event: `ticket.updated`

Sent whenever a ticket's status changes (e.g. `open` -> `in_progress`), and also whenever the agent posts a message into a ticket thread outside a status change — most notably a feature-request scoping round asking a question or wrapping up. `ticket` and `changes` are an evolving shape, not a stable slim DTO — read only the fields you need, and branch on which keys `changes` actually carries rather than assuming `status` is always present.

```json
{
  "event": "ticket.updated",
  "timestamp": "2026-05-12T12:03:00Z",
  "ticket": { "_id": "6650...", "status": "in_progress", "appId": "...", "endUser": {}, "reason": "low_confidence", "intent": "QA", "summary": "...", "priority": "medium" },
  "changes": { "status": "in_progress" }
}
```

A scoping round's message carries `changes.agentMessage` instead of `changes.status` — the question(s) or wrap-up it just posted to the ticket thread:

```json
{
  "event": "ticket.updated",
  "timestamp": "2026-05-12T12:03:00Z",
  "ticket": { "id": "6650...", "displayId": "ACME-0042" },
  "changes": { "agentMessage": { "id": "6651...", "content": "Which actions matter most — close, assign, or both?" } }
}
```

The full scope document itself is never sent over the webhook — it is product-language content meant for your team on the Sortd portal ticket, not a machine-readable payload.

#### Event: `ticket.resolved`

Sent in addition to `ticket.updated`, specifically when a ticket's status becomes `resolved`.

```json
{
  "event": "ticket.resolved",
  "timestamp": "2026-05-12T12:05:00Z",
  "ticket": { "_id": "6650...", "status": "resolved" },
  "resolution": { "resolvedAt": "2026-05-12T12:05:00Z" }
}
```

#### Event: `conversation.resolved`

Sent when a conversation goes idle with no new messages after the agent replied and **no** ticket was created — i.e. the agent fully deflected the conversation without escalating.

```json
{
  "event": "conversation.resolved",
  "timestamp": "2026-05-12T12:10:00Z",
  "conversation": {
    "id": "6651...",
    "summary": "Last agent message, truncated to 200 chars.",
    "deflected": true
  }
}
```

#### Event: `bug_task.created`

Sent when a BugTask is created for a bug ticket — for both chat-escalated bugs and tickets raised directly (see "Raise a Ticket Directly" below).

#### Event: `bug_task.ready_for_review`

Sent when an AI-investigated bug fix is ready for developer review in the portal.

#### Events: `bug_task.qa_requested`, `bug_task.qa_passed`, `bug_task.qa_failed`

QA sign-off events (only on apps with QA enabled). `bug_task.qa_requested` — a fix was handed to QA. `bug_task.qa_passed` — QA passed it; a staging pass means it is ready to deploy, so use this to trigger your own deploy. `bug_task.qa_failed` — QA sent it back to development; includes `bugTask.note`. Each carries `bugTask.qaRound` and, when known, `bugTask.deployedTo`.

#### Events: `backlog_item.*` (the new names for the `bug_task.*` events)

Bug tasks and plans are now **backlog items**: a `bug`, a `task` or a `chore`. Every lifecycle event above is delivered a second time under its new name — `backlog_item.created`, `backlog_item.ready_for_review`, `backlog_item.analysis_failed`, `backlog_item.rejected`, `backlog_item.pr_created`, `backlog_item.merged`, `backlog_item.released`, `backlog_item.closed`, `backlog_item.qa_requested`, `backlog_item.qa_failed` and `backlog_item.qa_passed` — with a `backlogItem` object in place of `bugTask`:

```json
{
  "event": "backlog_item.released",
  "timestamp": "2026-10-09T12:00:00Z",
  "backlogItem": {
    "id": "6651...",
    "type": "bug",
    "origin": "ticket",
    "ticketIds": ["6650..."],
    "title": "Export fails on large files",
    "status": "released",
    "stage": "live"
  }
}
```

- `backlogItem` always carries `id`, `type` (`bug`, `task` or `chore`), `origin` (`ticket`, `qa_finding`, `internal`, `runtime_error`, `scope_addition` or `draft`) and `ticketIds` (the customer tickets it is linked to — empty for internal work), plus `parent` (`{ itemId, phaseId?, blocksParent }`) when it is nested under another item, and whatever the event itself carries (`reason`, `prUrl`, `prNumber`, `qaRound`, `deployedTo`, `note`).
- `bug_task.*` keeps its payload exactly as before and still covers only bugs and tasks (including tasks inside a plan phase). **Chores, and items nested under another item (a QA finding, for example), are announced only as `backlog_item.*`.**
- **A webhook endpoint that takes every event receives both names**, so for one release each bug or task event arrives twice — once per name. Deduplicate on the event suffix and the item `id`, or move your handler to `backlog_item.*` and ignore `bug_task.*`. The `bug_task.*` names are deprecated and will be removed after one release.
- Slack, Discord and Teams integrations receive `backlog_item.*` only when you tick it explicitly, so an integration set to "all events" is not double-posted.

#### Deprecated: the `bugTaskId` response field

A ticket can now be linked to several backlog items (and a plan ticket to a whole plan), so it no longer has one "bug task". The id the API used to return as `bugTaskId` — on the `ticket` object of `GET /api/tickets/:id`, on the embed ticket payload, and on the escalation-proposal confirm / decide responses — is **deprecated and kept for one release**. It is derived on read from the ticket's linked items (the item the ticket's progress follows), is absent when the ticket has no linked item, and is never stored. Do not build new code on it: internal callers of `GET /api/tickets/:id` also receive `linkedItems[]` (`{ id, title, type, stage, status }` for each item linked to the ticket), and the coding-assistant tools return `linkedItemIds`.

### Signature Verification

Sortd sends two headers with every webhook request: `X-Sortd-Timestamp` (unix seconds) and `X-Sortd-Signature` (hex HMAC-SHA256 digest, signed with the App's `SORTD_WEBHOOK_SECRET`). The signed string is `${timestamp}.${rawBody}` — **not** the raw body alone. Delivery is retried up to 4 attempts total (1 initial + 3 retries: 1s, 5s, 15s), 10s timeout per attempt.

Verify the raw request body with `SORTD_WEBHOOK_SECRET` before parsing or processing the event. Also reject requests where the timestamp is too old (replay protection) — 5 minutes is a reasonable window.

```ts
import crypto from 'crypto';

export function verifySortdWebhook(
  rawBody: string,
  timestamp: string,
  signature: string,
  secret: string,
) {
  const skewSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(skewSeconds) || skewSeconds > 5 * 60) return false;

  const expectedHex = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(expectedHex);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Read both headers in the route handler (`req.headers['x-sortd-timestamp']` / `req.headers['x-sortd-signature']`) and pass them alongside the raw body — in that order, matching the function signature above. **A common mistake is swapping the `timestamp` and `signature` arguments**; the function above takes `(rawBody, timestamp, signature, secret)` — verify your call site matches.

Webhook behavior to implement:

- Reject missing or invalid signatures with `401`.
- Reject stale timestamps (outside the replay window) with `401`.
- Return `2xx` quickly after storing or enqueueing the event.
- Deduplicate by `ticket.id` plus `event` (a retry will resend the same `ticket.id`).
- Never trust webhook payload fields until the signature is valid.
- Log enough context to debug failed deliveries without logging secrets.
- Route escalated tickets into the team's existing on-call or support inbox.

### API Verification

Verify:

- Opening a conversation for a signed-in user returns a Sortd `conversation._id`.
- Appending a message returns `202` and a `messageId`.
- The agent's reply appears in the conversation (either via SSE or a follow-up `GET`).
- The webhook rejects an invalid signature with `401`.
- The webhook accepts a valid signature and the escalated ticket lands in the app's support inbox.
- `SORTD_API_KEY` is not present in browser bundles or network responses.
- Invalid or missing server env vars fail clearly.

## Integrate With Your Own AI Agent

Use this when the client app **already has its own AI agent**. Sortd becomes one more tool that agent can call, so the app keeps a single chat interface and a single agent while Sortd supplies the grounded support answers behind it.

This is the agent-native variant of API integration — same `SORTD_API_KEY`, same conversations, same webhooks. The difference is that these endpoints return the turn's outcome as a **typed state** and wait for it, so the tool handler is a single `fetch` with no poll loop and nothing to infer from the wording of a reply.

### Who owns what

| Concern | Owner |
|---|---|
| Deciding when to consult Sortd | The client's agent — it chooses to call the tool |
| The content of a support answer | Sortd — relayed verbatim, never paraphrased |
| Conversation continuity | Sortd — open-or-reuse, keyed on `endUser.externalId` (plus `clientThreadId`, if the client runs a thread per chat) |
| Which chat a question belongs to | The client app — it supplies `clientThreadId` |
| End-user identity, and any attached files | The client app's own session and UI — never the model |
| Chat UI, cards, buttons | The client app |
| Actually filing a ticket | Sortd, and only after an explicit confirm call |

### The one rule that matters

**Relay Sortd's `message` to the user verbatim.** Sortd's answers are grounded in the tenant's own repo and knowledge base, and they pass a citation and a confidence gate before they are returned. If the calling agent re-summarises that text in its own words, every one of those guarantees is discarded at the last hop and the app is back to hallucinating about its own product. The client's LLM decides *when* to call the tool; it never rewrites *what* comes back. Most agent frameworks can return a tool result as the assistant turn directly — use that.

### Ask

One call per user turn. It opens or reuses the end user's conversation, runs the agent, and returns the outcome.

```http
POST https://support.example.com/api/agent/ask
Authorization: Bearer {SORTD_API_KEY}
Content-Type: application/json

{
  "endUser": { "externalId": "user_123", "email": "jane@acme.com", "name": "Jane" },
  "clientThreadId": "chat_7",
  "message": "Why does my export come out empty?",
  "attachments": [
    { "type": "screenshot", "url": "https://.../uploads/abc.png", "mimeType": "image/png", "filename": "empty-export.png", "sizeBytes": 45210 }
  ]
}
```

`200 OK`:

```json
{
  "conversationId": "6651...",
  "userMessageId": "6652...",
  "outcome": "answer",
  "message": "Open Settings → Billing and choose Export → CSV.",
  "citations": [{ "kbDocId": "kb_12", "snippet": "…", "url": "https://…" }],
  "deepLinks": [{ "label": "Billing", "route": "/settings/billing", "breadcrumb": "Settings > Billing" }]
}
```

Optional fields:

- `clientThreadId` — **your** id for the chat this question came from. Sortd keeps one live thread per `clientThreadId`, so a user with three chats open in your product has three separate support threads, each with its own history. Omit it and you get one shared thread per end user, which is right for a single support surface. See *One thread per chat* below.
- `conversationId` — resume a specific thread by Sortd's own id. Mutually exclusive with `clientThreadId`; prefer `clientThreadId` unless you already store our ids.
- `attachments` — screenshots, recordings and logs for **this** question, same shape as on `POST /api/tickets`. Upload first via `POST /api/upload`, then pass the object it returns. Without these the agent answers the question without seeing what the user is looking at.
- `clientRequestId` — an idempotency key. A retry with the same value replays the same turn instead of asking twice. **Send one on every call.** It is also the only way to recover a turn whose HTTP response you never received (see *Long turns*).
- `waitMs` — shorten the wait budget (capped server-side).

`endUser` may be omitted only when `conversationId` is supplied. Everything else: `400` missing `message` or an invalid attachment, `401` bad key, `402` `quota_exceeded`, `403` `end_user_mismatch` (see below), `404` unknown `conversationId`, `409` archived or message-capped conversation, `429` rate limited — the body's `scope` field says which ceiling tripped: `end-user` (default 30/min, keyed on `endUser.externalId` or `conversationId`, `ASK_RATE_PER_MIN`) or `app` (default 600/min tenant-wide, `ASK_APP_RATE_PER_MIN`, checked first). A fleet of end users no longer shares one 30/min tenant budget — `scope: 'end-user'` means slow that one user down, `scope: 'app'` means ask us to raise the ceiling.

**Sending both `conversationId` and `endUser.externalId`? They must agree.** If the thread you name belongs to a different end user, the call is refused with `403 { "code": "end_user_mismatch" }` rather than quietly filing one person's question into another person's support history — the API key is tenant-scoped, so inside your own product that boundary is ours to enforce and yours to rely on. The same guard is available, and works the same way, on `GET .../outcome` and the progress stream via an optional `endUserExternalId` query parameter: pass the end user your session says is asking, and a thread id that turns out to belong to someone else fails closed instead of returning their answer. It is skipped for a thread opened pre-sign-in (one with an `anonymousId` and no `externalId`), so the flow below keeps working after the user signs in.

**No `externalId` yet?** Unlike `POST /api/conversations`, `ask` does not accept `endUser.anonymousId` — only `externalId` or an existing `conversationId`. For a user browsing before sign-in, open the thread once with `POST /api/conversations` using `endUser.anonymousId` (a stable id your client generates per device or session), then pass the returned `conversationId` to every `ask` call for that session. This matters most for mobile: an app offering support before login has no `externalId` to send, and sending a made-up one would fragment that person's history the moment they actually sign in.

### Attachments

Attachments on `ask` follow the same upload flow as everywhere else in this guide — the canonical walkthrough (upload, video presign + verify, frame sampling, document handling) lives in *Raise a Ticket Directly → Attachments: Video and Documents* below. This section covers only what is specific to an agent-native integration.

**Video needs frames or a transcript, or the agent cannot see it.** A bare video attachment (just `url`, `mimeType`, `durationSeconds`) is stored but invisible to the agent — vision analysis runs on sampled frames or narration, never the raw file. Sample up to 6 frames client-side the same way the embed widget does (see `extractVideoFrames` in the widget's composer for the exact algorithm: evenly spaced timestamps, skipping the first ~5% and the last 0.3s), upload each frame as an ordinary image via `POST /api/upload`, and set them on `media.frames`; or transcribe the audio via `POST /api/transcribe` and set `media.transcript`. Supply neither and the response carries a `warnings` entry saying the recording is invisible to the agent — treat that as a signal your handler needs frames or a transcript, not routine noise.

**Documents are actually read.** `application/pdf`, `.docx`, `.xlsx`, `.pptx`, `text/plain`, `text/csv`, and `application/json` all have their text extracted at upload time and inlined into the agent's context (capped, truncation marked when it happens). A scanned PDF with no extractable text layer falls back to rendered page images through the same vision path a screenshot uses. `.doc`, `.xls`, and `.ppt` are rejected at upload with a message telling the user to save as the modern format and re-upload; macro-enabled Office formats remain rejected outright.

**`warnings: string[]`** is an optional field alongside `outcome` on every response shape below — the `200`, the `202` long-turn response, and the replayed-idempotent-request path. It is present only when something was accepted but will not do what you probably expect — today, exclusively the bare-video case above. Log it during rollout: it is the one signal an attachment silently became a no-op.

**Never ship `SORTD_API_KEY` in a mobile app.** It is a server credential. A mobile client calls its own backend, and that backend calls Sortd — the device never holds the key. The same applies to uploads: either your backend proxies the upload (`POST /api/upload` called server-side), or your backend requests the presigned URL (`POST /api/upload/presign`) and hands only that URL to the device to `PUT` against directly.

### One thread per chat

If your assistant has a concept of separate chats, pass your own chat id as `clientThreadId` on every call:

```json
{
  "endUser": { "externalId": "user_123" },
  "clientThreadId": "chat_7",
  "message": "still not exporting"
}
```

Sortd opens that thread on first use and resumes it thereafter. Threads are independent: they never archive one another, they keep their own transcripts, and a ticket raised from one carries only that thread's context. Pass the same value to `POST /api/tickets` (or the thread's `conversationId`) so the ticket lands against the chat the user actually raised it from.

Each distinct `clientThreadId` counts as one conversation against your plan, no matter how many messages it carries.

### The five outcomes

Branch on `outcome` — never on the wording of `message`.

| `outcome` | What happened | What the client agent should do |
|---|---|---|
| `answer` | Sortd answered | Render `message` verbatim. Done. |
| `needs_input` | Sortd needs a clarification first (`question.text`, optional `question.options`) | Render `message`. Send the user's next reply to `ask` again with the same `conversationId`. |
| `needs_confirmation` | Sortd wants to open a support ticket and is waiting on the user | Render your own Yes/No card, then call **Decide** below. |
| `escalated` | A ticket already exists (moderation, a sensitive topic, or a system error — these never ask first) | Render `message`, plus your own ticket reference from `ticket.displayId`. |
| `no_reply` | The turn finished without producing a reply (cancelled, or a duplicate that was skipped) | Nothing to render. Safe to retry. |

On `needs_confirmation`, `proposal.pattern` tells you what `message` is: `with-answer` means Sortd already answered and `message` is that answer, with `proposal.question` a follow-up offer; `no-answer` means Sortd could not answer and `message` *is* the offer. In both cases `proposal.question` is the exact prompt to put next to the Yes/No control.

### Decide (resolving the ticket confirmation)

```http
POST https://support.example.com/api/conversations/:conversationId/escalation-proposals/:messageId/decide
Authorization: Bearer {SORTD_API_KEY}
Content-Type: application/json

{ "decision": "confirm" }
```

`messageId` is `proposal.messageId` from the ask response. `200 OK`:

```json
{
  "ok": true,
  "decision": "confirm",
  "message": "Got it — opening a ticket for our team now.",
  "ticket": { "id": "6653...", "displayId": "TKT-42" }
}
```

`decision: "decline"` files nothing and returns the transcript's replacement text with no `ticket`. Either decision is final: a second call returns `409 { "code": "already-processed" }`, so a double-click or a retry can never produce two tickets.

Do **not** resolve a confirmation by posting the user's "yes" back as a chat message — that turns a consent step into a guess. This endpoint is the only deterministic path, and it is why an agent integration reaches parity with the embed widget's own card.

### After a Ticket Exists

**A follow-up sent through `ask` is chat, not a ticket reply.** Once `decide` or an `escalated` outcome has produced a ticket, `ask` keeps answering further support questions in the same thread, but it never posts into the ticket a human is looking at. Once a ticket exists, route anything meant for that ticket — more detail, a screenshot found later, "any update?" — through:

```http
POST https://support.example.com/api/tickets/:ticketId/messages
Authorization: Bearer {SORTD_API_KEY}
Content-Type: application/json

{ "message": "...", "endUser": { "externalId": "user_123" } }
```

See *Raise a Ticket Directly → Reply to an Existing Ticket* below for the full request/response shape, the reopen-on-reply behavior, the idempotency key, and the `403 end_user_mismatch` guard. Read support's side of the same thread with the paired `GET` of that path.

Getting this wrong is silent — `ask` still returns `200`, so nothing looks broken. The reply just never reaches support.

### Long turns

A complex, code-grounded turn can outrun the wait budget. Then `ask` returns `202`:

```json
{
  "status": "processing",
  "conversationId": "6651...",
  "userMessageId": "6652...",
  "outcomeUrl": "/api/conversations/6651.../outcome?afterMessageId=6652...",
  "streamUrl": "/api/agent/conversations/6651.../stream?afterMessageId=6652...",
  "pollAfterMs": 3000
}
```

Poll `outcomeUrl` (same Bearer auth) at the interval `pollAfterMs` gives you. It returns `{ "status": "processing" }` until the turn settles, then exactly the same envelope `ask` would have returned — so the client agent has one shape to handle either way. Treat more than about five minutes of `processing` as failed and re-ask with the same `clientRequestId`.

Use `pollAfterMs` rather than an interval of your own: `outcome` is rate limited per conversation (default 60/min, `OUTCOME_RATE_PER_MIN`) as well as tenant-wide (default 600/min, `OUTCOME_APP_RATE_PER_MIN`, checked first), and a `429` from it carries the same `scope` field `ask` uses — `conversation` or `app`.

**`202` is a normal outcome, not an error.** The wait ceiling is a limit on how long we will hold an HTTP connection open, not a limit on how long a turn may take — and if your tenant has a raised planner wall-clock budget, hard turns will exceed it routinely. Build the poll (or the stream below) on day one rather than treating `202` as an edge case, and note that `waitMs` is clamped server-side, so asking for a longer wait does not avoid this.

### Streaming progress while a turn runs

A code-grounded turn can take a minute. If your chat UI needs to show something during it, subscribe to `streamUrl` — Server-Sent Events over the same Bearer auth:

```http
GET https://support.example.com/api/agent/conversations/:conversationId/stream?afterMessageId=:userMessageId
Authorization: Bearer {SORTD_API_KEY}
```

Three event types:

| Event | Payload | Use it for |
|---|---|---|
| `tool-activity` | `{ skillName, phase: 'start' \| 'done' \| 'error', queryLabel? }` | A live "searching your docs…" line. `queryLabel` is a short human-readable summary of what the step is looking for. |
| `message` | `{ messageId, role, content, headline?, steps?, citations?, deepLinks?, isBanner?, timestamp }` | Rendering the reply as it lands, instead of after the poll. **`content` is not the whole answer when `headline`/`steps` are present** — see *Rendering an agent answer* below. |
| `status` | `{ status, conversationId }` | `running` / `idle` / `failed` for this turn. |

With `afterMessageId` the stream is scoped to that one turn and **closes itself when the turn finishes**, so you do not have to decide when to hang up. Omit it to watch the whole conversation and close it yourself.

Two things to design around:

- **It is a progress channel, not a result channel.** Events are best-effort — `tool-activity` is explicitly droppable under load, and because there is no replay, a subscriber that connects late misses whatever was already published. The authoritative result is always the envelope from `ask` or `outcome`. On any disconnect, fall back to the poll; do not build reconnect-and-resume logic.
- **This is a server-to-server stream.** It authenticates with `SORTD_API_KEY`, so your backend subscribes and relays to the browser over your own channel. Never open it from a browser — that would ship the key to the client. (The embedded widget has its own end-user-scoped stream for that.)

**If the connection drops instead of returning anything, retry `ask` with the same `clientRequestId`.** Nothing is written to the socket while we hold the request open, so a proxy or client-side timeout on your side of the wire is indistinguishable from a dead connection — and since you never received a `userMessageId`, there is no `outcomeUrl` to poll. The retry re-attaches to the turn already running: it never asks the agent twice, and it returns that turn's answer if it has since finished. Retrying **without** a `clientRequestId` asks the same question a second time. The server-side wait is capped below common proxy timeouts so this stays rare, but any integration that runs long turns should handle it.

### The tool definition

Give the client's agent this tool. It works verbatim in OpenAI and Anthropic function calling.

```json
{
  "name": "ask_sortd_support",
  "description": "Answer a question about how this product works, or report a problem the user is hitting. Returns a grounded support answer, a clarifying question, or an offer to open a support ticket. Always relay the returned 'message' to the user verbatim — never rewrite, summarise, or add to it.",
  "input_schema": {
    "type": "object",
    "properties": {
      "question": {
        "type": "string",
        "description": "The user's question or problem report, in their own words."
      }
    },
    "required": ["question"]
  }
}
```

Note what the tool does **not** take, and must not:

- **No identity argument.** The end user comes from the app's own session inside the handler, never from the model — otherwise a user could ask the agent to look up someone else's support history.
- **No attachment argument.** A model cannot produce a storage URL; if it tries, it will invent one. The handler attaches whatever the user actually uploaded in the current turn, read from the app's own UI state.
- **No thread argument.** The handler knows which chat it is running in; the model does not need to be told, and a model-chosen thread id would mix unrelated conversations.

Everything the model supplies is the question itself. Everything else is supplied by trusted app context.

```ts
type SortdAttachment = {
  type: 'screenshot' | 'file' | 'video';   // strict enum; anything else is a 400
  url: string;                             // must come from POST /api/upload
  mimeType: string;
  filename: string;
  sizeBytes: number;
  durationSeconds?: number;                // video only
  media?: {                                // video only — see Attachments above
    frames?: { url: string; atSeconds: number }[]; // client-sampled preview frames
    transcript?: string;                           // narration/audio transcript
  };
};

type SortdTurn = {
  conversationId: string;
  userMessageId: string;
  outcome?: 'answer' | 'needs_input' | 'needs_confirmation' | 'escalated' | 'no_reply';
  status?: 'processing';
  message?: string;
  citations?: Array<{ kbDocId: string; snippet: string; url?: string }>;
  deepLinks?: Array<{ label: string; route: string; breadcrumb: string }>;
  question?: { text: string; options?: string[] };
  proposal?: { messageId: string; reason: string; pattern: 'with-answer' | 'no-answer'; question: string };
  ticket?: { id: string; displayId?: string };
  /** 202 only — poll this, at pollAfterMs, for the authoritative envelope. */
  outcomeUrl?: string;
  /** 202 only — optional SSE progress channel while the turn runs. */
  streamUrl?: string;
  pollAfterMs?: number;
  /** Present only when an attachment was accepted but won't do what you expect — see Attachments above. */
  warnings?: string[];
};

/** Tool handler. `user` comes from the app's session, never from the model. */
export async function askSortdSupport(
  question: string,
  ctx: {
    user: { id: string; email?: string; name?: string };
    /** This chat's id. Sortd keeps one live thread per value. */
    threadId?: string;
    /** Files the user attached to THIS turn, already uploaded via POST /api/upload. */
    attachments?: SortdAttachment[];
    /**
     * Idempotency key for this turn. Generate it ONCE per user turn and reuse it
     * on every retry — that is what makes a retry re-attach to the running turn
     * instead of asking the agent the same question twice.
     */
    requestId?: string;
  },
): Promise<SortdTurn> {
  const { user, threadId, attachments, requestId } = ctx;
  const res = await fetch(\`\${process.env.SORTD_BASE_URL}/api/agent/ask\`, {
    method: 'POST',
    headers: {
      authorization: \`Bearer \${process.env.SORTD_API_KEY}\`,
      'content-type': 'application/json',
    },
    body: JSON.stringify({
      endUser: { externalId: user.id, email: user.email, name: user.name },
      message: question,
      clientThreadId: threadId,
      clientRequestId: requestId,
      attachments,
    }),
  });

  if (!res.ok) throw new Error(\`Sortd ask failed: \${res.status} \${await res.text()}\`);
  return res.json();
}

export async function decideSortdTicket(
  conversationId: string,
  messageId: string,
  decision: 'confirm' | 'decline',
) {
  const res = await fetch(
    \`\${process.env.SORTD_BASE_URL}/api/conversations/\${conversationId}/escalation-proposals/\${messageId}/decide\`,
    {
      method: 'POST',
      headers: {
        authorization: \`Bearer \${process.env.SORTD_API_KEY}\`,
        'content-type': 'application/json',
      },
      body: JSON.stringify({ decision }),
    },
  );

  if (!res.ok) throw new Error(\`Sortd decide failed: \${res.status} \${await res.text()}\`);
  return res.json() as Promise<{ ok: true; message: string; ticket?: { id: string; displayId?: string } }>;
}
```

### What to add to the client agent's system prompt

Four instructions, adapted to the app's own wording:

1. *"For any question about how this product works, or any report that something is broken, call `ask_sortd_support` rather than answering yourself — you do not have access to the product's source code or knowledge base, and it does."*
2. *"Relay the tool's `message` field to the user exactly as written. Do not summarise it, reword it, or add your own explanation around it."*
3. *"If the tool returns `needs_confirmation`, show the user the confirmation prompt and wait. Never tell the user a ticket has been created unless a decide call returned one."*
4. *"If the user attached a screenshot or recording, still call the tool — their attachment is forwarded automatically. Never describe an image yourself."*

The third line matters most: the surrounding UI is the client app's, so only the client app can claim a ticket exists — and claiming one that does not is the single worst failure mode of this integration.

The fourth avoids a subtler one. Attachments are forwarded by the handler, not by the model, so a model that tries to be helpful by describing the screenshot itself is describing an image it was never shown. Sortd receives the image directly and reasons over what is actually in it.

### Own-Agent Verification

Verify:

- Calling `ask` for a signed-in user returns `200` with an `outcome` and a `conversationId`.
- Asking the same question twice with the same `clientRequestId` produces one conversation turn, not two.
- Two different `clientThreadId` values for the same user return two different `conversationId`s, and neither archives the other. Re-asking on the first still resumes it.
- A question sent with an `attachments` array returns an answer that refers to what is actually in the image — not a generic "please attach it again", and not a description of something that isn't there. Check the conversation in the portal: the attachment should be visible on the user's message.
- An attachment with `type: "image"` (or any value outside `screenshot`/`file`/`video`) is rejected with `400`. If a bad value ever returns `2xx`, stop and re-check — that is the silent-drop failure this API is designed to make impossible.
- A question the knowledge base cannot answer comes back as `needs_confirmation`, and the app renders its own Yes/No card.
- `decide` with `confirm` returns a `ticket.displayId`, and that ticket appears in the Sortd portal inbox.
- Calling `decide` a second time returns `409`.
- `decide` with `decline` creates no ticket.
- A `202` response's `outcomeUrl` eventually returns the same envelope shape as a `200`.
- The agent's user-facing reply is byte-identical to the `message` Sortd returned.
- Attaching a bare video (no `media.frames`, no `media.transcript`) returns a `warnings` entry naming the attachment; adding frames or a transcript makes it disappear.
- A `429` from `ask` carries `scope: 'end-user'` when one identity is hammering the endpoint, and `scope: 'app'` when the whole tenant ceiling trips.
- Two questions sent in quick succession on the same thread each come back with their own answer. Neither returns `no_reply` — that outcome means cancelled or duplicate, never "the other turn was still running".
- An `ask` naming a `conversationId` that belongs to a different `endUser.externalId` returns `403 end_user_mismatch`, not that other person's thread.
- Subscribing to a `202`'s `streamUrl` yields `tool-activity` events while the turn runs, and the stream closes on its own once the turn finishes.
- Killing the stream mid-turn changes nothing about the result: the poll still returns the full envelope.
- A reply sent through `POST /api/tickets/:ticketId/messages` appears on the ticket thread in the portal; the same text sent through `ask` afterward does not.
- A user with no `externalId` yet gets a working thread by opening one via `POST /api/conversations` with `endUser.anonymousId`, then passing that `conversationId` to `ask`.
- `SORTD_API_KEY` appears in no browser bundle or network response — every call above is server-to-server.

## MCP Integration

MCP integration lets Sortd's AI agents call the client app's own API routes as tools — for example, to look up a user's account, query order history, or fetch resource details while resolving a support ticket. No new endpoints or business logic need to be invented — Sortd calls the app's existing routes.

That does not mean MCP is zero backend work. Sortd authenticates with a **service token** (a bearer token, not a user session), and most product APIs were built to authenticate a browser session or cookie, not a service token — a route built that way returns `401` to Sortd until a service-token auth path is added. Realistically, wiring up a route to serve as an MCP tool can require: allowing the request through any app-level auth guard sitting in front of the route (usually a single change that unlocks every route behind it, do this first), adding service-token auth alongside (not instead of) session auth, verifying the identity claim, and enforcing that the request only touches the resource it was scoped for. The sections below cover each of these, in the order to implement them — read all of them before assuming a route is "just a bearer check away" from working.

### What Sortd Configures

The developer sets two values in the Sortd portal under **My Apps → [App] → MCP Server**:

- **Backend API URL**: the base URL of the app's API, for example `https://api.yourapp.com`. Sortd prepends this to every generated route path when making tool calls.
- **Backend API token**: Sortd generates this — the developer clicks **Generate token**, copies the `sk_sortd_...` value shown once, and adds it to their app as `SORTD_TOKEN`. Sortd stores the token encrypted and never displays it again after the initial reveal.

Generated tools land in the portal as **pending review** and are not callable until an admin approves each one individually (My Apps → [App] → MCP Server → tool list). Confirming the backend URL and token are correct is not enough — if no tool calls are arriving, check that the tools you expect have been approved.

### What Sortd Sends on Every Tool Call

When the AI agent invokes an MCP tool, Sortd makes an HTTP request to the app's backend:

```
POST https://api.yourapp.com/api/some-route
Authorization: Bearer <backend-api-token>
X-Sortd-Identity-Claim: <signed-jwt>
Accept: application/json
Content-Type: application/json
```

`Content-Type` is only present on tool calls that send a body (POST/PUT/PATCH) — GET/DELETE calls omit it. `Accept: application/json` is sent on every call.

The app's bearer token check is the first verification layer, but it is rarely the only code change — see the sections below for the global-auth-guard, identity-claim, and resource-scoping requirements that most real routes also need.

### Allow Sortd Through Your Auth Boundary (Do This First)

Before adding bearer-token checks, identity-claim verification, or resource scoping to individual routes, find out whether the app has an app-level auth boundary that runs before any route handler — Next.js `middleware.ts`/proxy, an Express `app.use(requireAuth)` mounted ahead of the routers, Django middleware, a Rails `before_action` on `ApplicationController`. If one exists, it will reject Sortd's request with `401` before the route ever executes, no matter what the route itself checks — so it needs to be handled first, not discovered as a gotcha after the rest is already built.

This is a single change, not per-route work: allowing Sortd's `SORTD_TOKEN` bearer token through that one boundary makes every route behind it reachable at once. The per-route work that remains — identity claim verification and resource scoping — only applies to the specific routes exposed as tools, and even that is normally a scoped middleware applied to that route group, not edits to each handler.

**Symptom (if this step is skipped):** tools are approved in the portal, the gateway reports healthy, but every tool call returns `401` and — this is the tell — **no log line appears inside the route handler at all**. The request never reached it.

**Fix:** add an explicit exemption in that boundary for requests carrying a valid `SORTD_TOKEN` bearer token, then let the route handler do the real work (identity claim + resource scoping). The exemption must be additive only — it authenticates Sortd's service token as its own credential type; it must not weaken or bypass session auth for normal end users.

### Bearer Token Verification (Required)

The app must check the `SORTD_TOKEN` value on incoming requests from Sortd. This is necessary but usually not sufficient on its own — treat it as the first of several checks, not the only one. Add it to the middleware or route handler protecting the routes Sortd will call:

```ts
// Express / Next.js / any framework
if (req.headers.authorization !== `Bearer ${process.env.SORTD_TOKEN}`) {
  return res.status(401).json({ error: 'unauthorized' })
}
```

Add `SORTD_TOKEN=<value-from-portal>` to the app's server-only environment. If the routes Sortd calls are already protected by other auth middleware, add this check in addition — or scope it to the specific paths Sortd targets.

### Identity Claim Verification (Required for Per-User Scoping)

The `SORTD_TOKEN` bearer check only proves that *some* Sortd gateway made the request — it is one secret shared across the whole app, so it cannot tell you *which end user* or *which ticket* the call is for. Every Sortd tool call also carries an `X-Sortd-Identity-Claim` header containing a short-lived RS256 JWT that does carry that information. Skip verifying it only if every route Sortd calls returns exclusively tenant-wide, non-user-specific data (for example, a public status page). If any exposed route returns or mutates a specific user's data — "what is wrong with THIS user's account?" is the whole reason MCP tools exist — verify this header and use its claims to scope the response; otherwise a valid bearer token lets the agent (or anyone who obtains the token) read or act on any user's data through the route.

Verifying the claim gives the app:

- Cryptographic proof the request came from Sortd (not just from any holder of the bearer token).
- The end-user ID (`sub`) and ticket ID (`ticket_id`) that triggered the tool call, required to scope the response to the right user (see Resource Scoping below) and useful for audit logging.

The JWT payload:

| Claim | Value |
|---|---|
| `iss` | `sortd` |
| `aud` | Hostname of the app's backend URL |
| `sub` | End-user ID from the Sortd ticket |
| `ticket_id` | Sortd ticket that triggered this call |
| `exp` | 4 minutes from issue time |

Sortd publishes its public keys at `https://support.example.com/.well-known/jwks.json`. Use this endpoint with the `jose` library to verify the token.

#### Express

```ts
import { createRemoteJWKSet, jwtVerify } from 'jose';
import type { RequestHandler } from 'express';

const JWKS = createRemoteJWKSet(new URL(`${process.env.SORTD_BASE_URL}/.well-known/jwks.json`));

export const verifySortdIdentity: RequestHandler = async (req, res, next) => {
  const raw = req.headers['x-sortd-identity-claim'] as string | undefined;
  if (!raw) { res.status(401).json({ error: 'Unauthorized' }); return; }
  try {
    const { payload } = await jwtVerify(raw, JWKS, {
      issuer: 'sortd',
      audience: new URL(process.env.APP_BASE_URL!).host,
      algorithms: ['RS256'],
    });
    (req as any).sortdClaim = payload;
    next();
  } catch {
    res.status(401).json({ error: 'Unauthorized' });
  }
};
```

Apply to routes that should only be callable by Sortd:

```ts
router.get('/api/users/:id', verifySortdIdentity, getUserHandler);
```

#### NestJS

```ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { createRemoteJWKSet, jwtVerify } from 'jose';

@Injectable()
export class SortdIdentityGuard implements CanActivate {
  private jwks = createRemoteJWKSet(new URL(`${process.env.SORTD_BASE_URL}/.well-known/jwks.json`));

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest();
    const raw = req.headers['x-sortd-identity-claim'];
    if (!raw) throw new UnauthorizedException();
    try {
      const { payload } = await jwtVerify(raw, this.jwks, {
        issuer: 'sortd',
        audience: new URL(process.env.APP_BASE_URL!).host,
        algorithms: ['RS256'],
      });
      req.sortdClaim = payload;
      return true;
    } catch {
      throw new UnauthorizedException();
    }
  }
}
```

Apply as a guard on controllers or individual routes:

```ts
@UseGuards(SortdIdentityGuard)
@Get('users/:id')
getUser(@Req() req: Request) {
  const { sub: endUserId, ticket_id } = (req as any).sortdClaim;
  // scope response to this user if needed
}
```

#### Next.js App Router

```ts
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { NextRequest, NextResponse } from 'next/server';

const JWKS = createRemoteJWKSet(new URL(`${process.env.SORTD_BASE_URL!}/.well-known/jwks.json`));

export async function verifySortdIdentity(req: NextRequest) {
  const raw = req.headers.get('x-sortd-identity-claim');
  if (!raw) return null;
  try {
    const { payload } = await jwtVerify(raw, JWKS, {
      issuer: 'sortd',
      audience: new URL(process.env.APP_BASE_URL!).host,
      algorithms: ['RS256'],
    });
    return payload as { sub: string; ticket_id: string };
  } catch {
    return null;
  }
}

// In a route handler:
export async function GET(req: NextRequest) {
  const claim = await verifySortdIdentity(req);
  if (!claim) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  // claim.sub = end-user ID, claim.ticket_id = Sortd ticket
}
```

### Resource Scoping (Required)

A verified signature only proves the claim is authentic — it says nothing about whether the claim matches the resource the request is asking for. A claim minted for ticket A is still a validly-signed JWT when replayed against ticket B's endpoint. Every route that takes a resource id (a ticket, order, user, document, etc.) must compare that id against the matching field on the claim and reject a mismatch with `403`:

- Ticket-scoped route (`/api/tickets/:ticketId/...`) → require `ticketId === claim.ticket_id`.
- User-scoped route (`/api/users/:userId/...`) → require `userId === claim.sub`.

```ts
// Express example — apply after verifySortdIdentity
export const enforceTicketScope: RequestHandler = (req, res, next) => {
  const claim = (req as any).sortdClaim;
  if (req.params.ticketId !== claim.ticket_id) {
    res.status(403).json({ error: 'forbidden' });
    return;
  }
  next();
};

router.get('/api/tickets/:ticketId/orders', verifySortdIdentity, enforceTicketScope, getOrdersHandler);
```

Without this check, verifying the claim's signature buys nothing at the data layer: any single valid claim — or any request carrying just the shared bearer token, if the claim isn't checked at all — can enumerate every ticket or user in the system by varying the id in the URL. The claim's `sub` / `ticket_id` values only protect data once a handler actually compares them against the resource being requested.

### HTTP Status Codes (Required)

The agent reads the status code to decide what to do next, so returning the correct one is part of making the tool usable, not just correct:

- **400** — malformed argument (not a valid ObjectId/UUID, wrong shape). Lets the agent correct the argument and retry. The common cause is passing a tool argument straight into a DB lookup (`findById(arg)`); an invalid value throws inside the driver and a generic error handler turns it into a `500`. Validate the shape of resource ids before the lookup and return `400` on failure.
- **401** — missing or invalid credentials (bearer token or identity claim).
- **403** — credentials are valid but the resource doesn't match the claim (see Resource Scoping above).
- **404** — the resource genuinely doesn't exist for a valid, correctly-scoped request.
- **500** — reserved for actual server faults. Never let a `500` stand in for a `400`; an opaque `500` gives the agent no signal to act on, so it gives up and escalates to a human instead of retrying with a corrected argument.

### MCP Verification

Verify:

- Sortd can reach the app's backend URL from the public internet (or the network Sortd is deployed on).
- Each route Sortd calls has a service-token auth path — not only a user-session/cookie check — so it doesn't 401 a legitimate service-token request.
- `SORTD_TOKEN` is set in the app's server environment and matches the value generated in the Sortd portal.
- Requests carrying the correct `Authorization: Bearer <SORTD_TOKEN>` header are accepted on the relevant routes.
- Unauthenticated requests to those routes return `401`.
- If the app has a global auth boundary (`middleware.ts`/proxy, `app.use(requireAuth)`, etc.) in front of the routes, it explicitly allows requests carrying a valid `SORTD_TOKEN` through — otherwise every tool call 401s before the route handler runs, with no log line inside the handler.
- The tools you expect to be called are approved in the portal (My Apps → [App] → MCP Server) — generated tools start as pending review and are not callable until approved.
- Unless every exposed route is tenant-wide and non-user-specific: a request with a missing or forged `X-Sortd-Identity-Claim` header returns `401`, and `req.sortdClaim.sub` / `.ticket_id` contain the expected end-user and ticket IDs.
- Resource scoping is enforced: a valid claim for one ticket/user requesting a different ticket's/user's resource returns `403`, not `200`.
- A malformed resource id (invalid ObjectId/UUID) returns `400`, not `500`.

## Raise a Ticket Directly (Optional)

Works alongside either Embed or API integration. End users can file a ticket directly — skipping the conversation entirely — through two endpoints that both reuse the same underlying ticket-creation path as chat escalations: ticket numbering, webhooks, and downstream processing all behave identically. The only difference is there is no AI chat reply; a background job enriches the ticket after the fact (see below).

Use this when the client app wants a "Report a problem" or "Raise a ticket" affordance that should not require the user to describe their issue conversationally first.

### Server-to-Server: `POST /api/tickets`

For Tier-1 API integrations raising a ticket on behalf of one of the app's own end users. Authenticate with `Authorization: Bearer {SORTD_API_KEY}` — same as the conversation endpoints.

```http
POST https://support.example.com/api/tickets
Authorization: Bearer {SORTD_API_KEY}
Content-Type: application/json

{
  "endUser": {
    "externalId": "user_123",
    "email": "jane@acme.com",
    "name": "Jane Doe",
    "appTenantId": "tenant_acme"
  },
  "subject": "CSV export broken",
  "description": "The export downloads an empty file",
  "issueType": "bug",
  "attachments": [
    { "type": "screenshot", "url": "https://.../uploads/abc.png", "mimeType": "image/png", "filename": "screenshot.png", "sizeBytes": 45210 }
  ],
  "reportedFrom": { "url": "/billing/invoices/8f21", "label": "Invoice detail" },
  "clientRequestId": "optional-client-supplied-idempotency-key"
}
```

Field notes:

- `endUser.externalId` — required, the app's stable user ID.
- `description` — required, max 5000 chars.
- `subject` — optional, max 200 chars; falls back to a truncated `description` when omitted.
- `issueType` — required, one of `bug`, `question`, `feature_request`. Ticket creation is synchronous with no AI classification step beforehand, so this must come from the caller.
- `attachments` — optional, same shape as conversation-message attachments. Upload files first via `POST /api/upload` (images/documents) or `POST /api/upload/presign` (video) to get a `url`, then reference it here. **`type` is a strict enum: `screenshot` | `file` | `video`.** Any other value is a `400`. `POST /api/upload` returns the correct `type` alongside the `url` — echo it rather than deriving your own. Note `file` is not a safe fallback for an image: it stores fine but renders without a preview, so a screenshot sent as `file` reaches your support team as an unviewable attachment.
- `reportedFrom` — optional, `{ url?, label? }` (at least one required if the object is sent at all). Where in your app the user was when they reported this — surfaced on the ticket, threaded into the AI's investigation, and used for the "which areas generate the most tickets" breakdown in the portal. `url` can be an absolute URL or a path relative to your app; a client with no browser URL to report (an internal tool, an agent-to-agent integration) can send `label` alone. An invalid value is a `400` here (this endpoint is for developers hand-building the request); the embed widget's equivalent field is dropped silently instead.
- `conversationId` — optional. Binds the ticket to a specific thread. Omitted, the ticket attaches to this end user's current thread and copies its transcript into the ticket's context — which is wrong if the user raised it from a different chat. Pass this (or `clientThreadId`) whenever your product has more than one chat per user.
- `clientThreadId` — optional. Same per-chat thread key as `POST /api/agent/ask`; used only when `conversationId` is omitted.
- `clientRequestId` — optional idempotency key. Retrying with the same value returns the same ticket instead of creating a duplicate; safe against concurrent retries too.

`201 Created`:

```json
{ "ticket": { "id": "6650...", "displayId": "ACME-0142", "status": "open" } }
```

The ticket number comes back synchronously — no polling needed. The response also carries an optional `warnings: string[]` — present only when an attachment was accepted but will not do what you probably expect (today: a video with no frames and no transcript, see *Attachments: Video and Documents* below).

Errors: `400` if `endUser.externalId`, `description`, or a valid `issueType` is missing, an attachment has an invalid `type`/`url` (the response names the offending index and value), or `reportedFrom` is present but has neither a valid `url` nor a `label`; `401` if the API key doesn't resolve; `403` if the org is suspended; `404` if `conversationId` doesn't exist under this app; `409` if `conversationId` belongs to a different end user; `429` (with `Retry-After: 3600`) if the per-app rate limit is hit (default 10/hour, `RAISE_ISSUE_RATE_PER_HOUR`).

### Embed Session: `POST /api/embed/tickets`

Same body/response shape, minus `endUser` — identity comes from the already-authenticated embed session (the same opaque `sessionId`/Bearer token used by the rest of the embed, not `SORTD_API_KEY`). This is what the embed widget's own "Raise an issue" button calls internally: end users see it as a form (issue-type picker, subject, description, drag/drop attachments) inside the "My Tickets" tab. Rate limited per end-user rather than per-app. An invalid `reportedFrom` here is dropped silently rather than rejected — the widget derives it from the host page, not from hand-written JSON.

If the client app uses the `embed.js` script loader, `window.SortdSupport.raiseIssue()` opens the widget directly to this form (requires `tickets` in `data-views`).

### Reply to an Existing Ticket

Once a ticket exists — created directly above or escalated from a conversation — this is how an end user (or the client app's own backend on their behalf) adds to it afterward. It is the server-to-server counterpart to the embed widget's own ticket thread, and the endpoint an Own-Agent integration needs once `decide` or an `escalated` outcome has produced a ticket (see *Integrate With Your Own AI Agent → After a Ticket Exists*): a message sent through `POST /api/agent/ask` after that point is chat, not a ticket reply, and support staff will never see it.

```http
POST https://support.example.com/api/tickets/:ticketId/messages
Authorization: Bearer {SORTD_API_KEY}
Content-Type: application/json

{
  "message": "Here's the console error you asked for.",
  "attachments": [
    { "type": "screenshot", "url": "https://.../uploads/console.png", "mimeType": "image/png", "filename": "console.png", "sizeBytes": 30210 }
  ],
  "endUser": { "externalId": "user_123" },
  "clientRequestId": "optional-idempotency-key"
}
```

`202 Accepted`:

```json
{ "messageId": "6654...", "ticket": { "id": "6650...", "status": "open" } }
```

Field notes:

- `message` — the reply text, max 5000 characters.
- `attachments` — optional, same shape and same rules as everywhere else in this guide (see *Attachments: Video and Documents* below).
- `endUser.externalId` — optional. When present it must match the ticket's own reporter; a mismatch returns `403 { "code": "end_user_mismatch" }` rather than silently attaching the reply to someone else's thread.
- `clientRequestId` — optional idempotency key. A retry with the same value returns the same `messageId` instead of posting twice.

A reply on a `resolved` or `closed` ticket **reopens it** — `ticket.status` in the response reflects the new status. Read the other side of the thread — support's own replies — with:

```http
GET https://support.example.com/api/tickets/:ticketId/messages
Authorization: Bearer {SORTD_API_KEY}
```

Errors: `400` invalid ticket id, or an empty `message` with no attachments; `401` bad key; `403` `end_user_mismatch`; `404` unknown or internal-only ticket; `429` rate limited — `scope: 'ticket'` (default 30/min per ticket, `TICKET_REPLY_RATE_PER_MIN`) or `scope: 'app'` (default 600/min tenant-wide, `TICKET_REPLY_APP_RATE_PER_MIN`, checked first).

### Attachments: Video and Documents

`POST /api/upload` (multipart/form-data, `file` field, max 10MB) now accepts `image/png|jpeg|webp|gif`, `application/pdf`, `text/plain`, `text/csv`, `application/json`, and modern Office documents (`.docx`, `.xlsx`, `.pptx`). It rejects `text/html`, `image/svg+xml`, and **macro-enabled** Office formats (`.docm`/`.xlsm`/`.pptm`) — those can all carry active content and the storage bucket is public-read. It also rejects legacy binary Office formats (`.doc`, `.xls`, `.ppt`) with a message telling the caller to save as the modern format and re-upload — there is no parser for them, so accepting them would only produce a file the agent can see but never read. A request that isn't multipart form data answers `400`, not `500`.

It responds with everything an attachment needs, including a valid `type`:

```json
{ "url": "https://.../uploads/abc.png", "type": "screenshot", "filename": "abc.png", "mimeType": "image/png", "sizeBytes": 45210 }
```

Pass that object straight through as an attachment. Deriving `type` yourself is the one place this API is easy to get wrong — an unrecognised value is rejected with a `400` naming the offending index.

**Documents are read, not just stored.** `application/pdf`, `.docx`, `.xlsx`, `.pptx`, `text/plain`, `text/csv`, and `application/json` all have their contents extracted at upload time and inlined into the agent's context (capped per file and in aggregate, truncation marked when it happens). A scanned PDF with no extractable text layer falls back to rendered page images through the same vision path a screenshot uses — the agent still sees it, just as an image rather than text. `.doc`, `.xls`, and `.ppt` are rejected at upload with a message telling the user to save as the modern format and re-upload — there is no parser for the legacy binary formats, so accepting them would only produce a file the agent can see but never read.

**Serving more than one end user through this key?** Pass `X-Sortd-End-User: <your-user-id>` on `POST /api/upload`, `/api/upload/presign`, and `/api/upload/verify` and the upload rate limit is bucketed per end user instead of tenant-wide — recommended for any multi-user integration, since without it one user's upload burst throttles every other user sharing the key.

For video, use `POST /api/upload/presign` instead — routing large files through a Next.js request body isn't viable.

```http
POST https://support.example.com/api/upload/presign
Authorization: Bearer {SORTD_API_KEY}
Content-Type: application/json

{ "mimeType": "video/webm", "filename": "screen-recording.webm", "sizeBytes": 20000000 }
```

Accepts `video/mp4`, `video/webm`, `video/quicktime`, max 50MB. Response:

```json
{
  "uploadUrl": "https://.../presigned-put-url...",
  "url": "https://.../uploads/abc.webm",
  "filename": "screen-recording.webm",
  "mimeType": "video/webm",
  "sizeBytes": 20000000
}
```

PUT the file directly to `uploadUrl` (bypasses the app server entirely):

```ts
await fetch(uploadUrl, {
  method: 'PUT',
  headers: { 'Content-Type': mimeType },
  body: file,
});
```

**Then verify it landed intact.** The presigned PUT's signature covers `Content-Type` only, not `Content-Length` — a client (or anything bypassing your UI) can PUT more bytes than it presigned for. Call this once the PUT resolves:

```http
POST https://support.example.com/api/upload/verify
Authorization: Bearer {SORTD_API_KEY}
Content-Type: application/json

{ "url": "https://.../uploads/abc.webm" }
```

`200 { "ok": true, "sizeBytes": 20000000 }` confirms the real object is within the cap. `413 { "ok": false, "error": "File too large (max 50MB)" }` means the object exceeded 50MB — Sortd deletes it server-side when this happens, so don't reference that `url` afterward. Same auth modes and rate-limit bucket as `/api/upload` and `/api/upload/presign`.

Once verified, use `url` — not `uploadUrl` — as the attachment's `url` when raising the ticket.

**A bare video attachment is invisible to the agent.** Storing the file and referencing its `url` is not enough — vision analysis runs on sampled frames or a transcript, never the raw video bytes, so a video with neither is a recording the agent is told exists but cannot see. Do one of:

- Sample up to 6 frames client-side — the same approach the embed widget uses (`extractVideoFrames` in the widget's composer: evenly spaced timestamps, skipping the first ~5% and the last 0.3s) — upload each frame as an ordinary image via `POST /api/upload`, and attach them as `media.frames`.
- Or transcribe the audio server-side via `POST /api/transcribe` (multipart, field name `audio`) and attach the text as `media.transcript`.

```json
{
  "type": "video",
  "url": "https://.../uploads/clip.webm",
  "mimeType": "video/webm",
  "filename": "clip.webm",
  "sizeBytes": 20000000,
  "durationSeconds": 42,
  "media": {
    "frames": [{ "url": "https://.../uploads/frame-1.webp", "atSeconds": 2.1 }],
    "transcript": "optional narration text"
  }
}
```

Send neither and the ticket-creation response carries a `warnings` entry naming the attachment — the signal that the recording will sit unseen until a human opens it, not that anything failed.

### Background Enrichment

After a ticket is created via either endpoint above, a background job enriches it asynchronously: classifies a `category` against the tenant's configured category list (if any), runs vision analysis on attached screenshots for bug reports (may raise `priority`, never lowers it), and — for `bug` issueType — creates a BugTask via the same pipeline chat-escalated bugs use (investigation, verified fix proposal, portal review, eventual PR).

This enrichment never posts a visible AI reply back to the end user — it only updates ticket fields and may kick off the bug pipeline. It can fire a second `ticket.updated` webhook shortly after creation once it completes, and can fire `bug_task.created` / `bug_task.ready_for_review` (see Webhooks above).

### Raise-a-Ticket Verification

Verify:

- `POST /api/tickets` with a valid App API key and a minimal body (`endUser.externalId`, `description`, `issueType`) returns `201` with a `ticket.id` and `displayId`.
- `POST /api/embed/tickets` from an authenticated embed session creates a ticket without an `endUser` block.
- Retrying either call with the same `clientRequestId` returns the same ticket, not a duplicate.
- A ticket raised with `issueType: "bug"` produces a BugTask (visible in the portal) shortly after creation.
- No AI chat reply is expected or shown for a directly-raised ticket.
- Video attachments go through `POST /api/upload/presign`, a direct `PUT`, and `POST /api/upload/verify` — not through `POST /api/upload`.
- An attachment with an unrecognised `type` is rejected with `400` — if you get a `201`, the file is stored. Confirm it renders on the ticket in the portal.
- A ticket raised with an explicit `conversationId` shows that thread as its context, not whichever chat the user last used.
- A video attachment with no frames and no transcript still returns `201`, but the response's `warnings` array names it — confirm the array disappears once frames or a transcript are attached.
- `POST /api/upload/verify` on an object that exceeds 50MB returns `413` and deletes the object — a ticket subsequently raised against that `url` should fail, not silently attach a missing file.
- A reply through `POST /api/tickets/:ticketId/messages` appears on the ticket thread in the portal and reopens a `resolved`/`closed` ticket; the same content sent through `POST /api/agent/ask` does not.
- An `endUser.externalId` on a reply that doesn't match the ticket's own reporter returns `403 end_user_mismatch`.

## Runtime Error Reporting (Optional)

Server-to-server only — this is called from the app's own backend, never a browser, so it needs no CORS and is not part of the Embed surface. When error tracking is on (**My Apps → [App] → Settings → Alerts → Track errors** — on by default for apps created from now on; older apps must switch it on), Sortd fingerprints reported exceptions and dedupes repeats into error groups you can triage in the portal. That needs no repo and spends no AI budget. Separately, the optional **Investigate with AI** switch (needs code-fix analysis and a connected repo) automatically starts the same code-fix investigation a human bug report would trigger once a group crosses an occurrence/source threshold, so a fix can land in the bug task queue before a customer ever files a ticket.

### `POST /api/errors`

Authenticate with `Authorization: Bearer {SORTD_INGEST_KEY}` — a dedicated **ingest key** (`sk_ingest_…`, portal **Settings → Alerts → Ingest keys**; shown once at creation). It authenticates this endpoint only. The legacy app key (`Bearer {SORTD_API_KEY}`, `sk_app_…`) is still accepted here and keeps working, but prefer an ingest key: it is revocable and rotatable on its own, and a leaked one cannot touch tickets. Call this from a global error handler, an unhandled-rejection hook, or middleware — wherever the app already catches exceptions server-side. **The more of the fields below you send, the more evidence the investigation agent starts from** — this works the same way Sentry's exception capture does: a stack trace alone tells it *where*; environment, handled/unhandled, and a few structured tags tell it *how bad* and *under what conditions*, which is what turns "found a plausible cause" into "found the actual root cause" on the first pass.

```http
POST https://support.example.com/api/errors
Authorization: Bearer {SORTD_API_KEY}
Content-Type: application/json

{
  "events": [
    {
      "name": "AppError",
      "message": "Failed to create the bill in Xero",
      "stack": "AppError: Failed to create the bill in Xero\n    at pushExpenseBill (src/services/xero/expense-bills.ts:184:15)",
      "cause": [
        { "name": "XeroHttpError", "message": "400 ValidationException: Account code 429 is archived" }
      ],
      "level": "error",
      "release": "1.4.2",
      "route": "/api/v1/expense-bills/:id/xero-bill",
      "url": "https://app.example.com/api/v1/expense-bills/8f21/xero-bill",
      "environment": "production",
      "handled": true,
      "sessionKey": "opaque-per-session-or-per-server-identifier",
      "userKey": "opaque-stable-hash-of-the-real-user",
      "serverName": "web-1",
      "requestId": "req_abc123",
      "context": { "feature": "checkout", "planTier": "pro" },
      "tags": { "feature": "billing", "integration": "xero" },
      "request": {
        "method": "POST",
        "statusCode": 500,
        "durationMs": 1840,
        "headers": { "content-type": "application/json" },
        "queryKeys": ["force"]
      },
      "breadcrumbs": [
        { "t": "2026-09-11T15:51:41Z", "type": "db", "message": "expense_bills.findById ok" },
        { "t": "2026-09-11T15:51:43Z", "type": "http", "message": "POST api.xero.com/api.xro/2.0/Bills -> 400", "data": { "status": 400 } }
      ]
    }
  ]
}
```

Field notes:

- `events` — required array, max 20 per request; anything beyond that is dropped silently, never rejected (a retrying client must not get a loop).
- `message` — required, max 500 chars. `stack` max 8000 chars, `release` max 64, `route` max 200, `url` max 500, `environment` max 32 — all silently truncated, not rejected.
- `environment` — e.g. `production`/`staging`/`preview`. Cheap to send (usually just `process.env.NODE_ENV` or your own deploy-target variable) and high-signal: it's the difference between "this is live and hurting real users" and "this only reproduces in staging."
- `handled` — `true` if your own code caught the exception and kept going, `false` if it reached an unhandled-exception/unhandled-rejection boundary (i.e. it crashed the request or process). This is the single strongest severity signal available at ingest — send it whenever you know which case you're in.
- `cause` — **the single highest-value field on this endpoint.** An ordered array of the errors your error was wrapping, outermost first, max depth 5 (`name` 200 chars, `message` 500, `stack` 4000). Application code almost always catches a library or vendor error and re-throws its own: `AppError: Failed to create the bill in Xero` says *what your code was doing*, while the wrapped `XeroHttpError: 400 ValidationException: Account code 429 is archived` says *what actually went wrong*. Send only the chain BELOW the error you are reporting — do not repeat the top-level error as its own first cause. Walk it with `err.cause` (ES2022, plus whatever your own wrapper class calls it) and `AggregateError.errors`. Without this field an investigation starts from your wrapper message alone and has to infer the vendor's reason from code.
- `url` — the full request URL; Sortd strips the query string and fragment before storing it, so don't hold back on including it because of query params.
- `eventId` — optional, max 100 chars of `A-Za-z0-9._:-`. A unique id per occurrence (e.g. a UUID generated when you capture the error; the reporter below sends `crypto.randomUUID()`). Sortd counts a repeated `eventId` for your app once for 24 hours, so retrying a failed or timed-out request cannot double-count. Anything else you send in this field is ignored.
- `sessionKey` — an opaque per-session or per-server identifier, **not** a raw session token or anything PII — hash your session id (e.g. `sha256(sessionId)`) before sending; Sortd additionally HMACs whatever you send before storing it, and discards only values that are empty or over 100 chars. This is what lets Sortd tell "50 occurrences from 1 developer hammering refresh" apart from "50 occurrences across 12 real users" before spending anything on an investigation — omit it and every event counts as one source.
- `context` — an optional flat object of small, structured tags that help the investigation, e.g. `{ feature: 'checkout', planTier: 'pro', flagsEnabled: ['new-cart'] }`. It is scrubbed for PII-shaped strings (emails, tokens, JWTs) before storage the same way `message` is, and the most recent value is surfaced directly to the investigating agent — but it is **not** a substitute for care: never put request bodies, local variables, or end-user identifying details (name, email, raw IDs tied to a real person) in here or in `stack`. Small labels describing *what the app was doing*, not *who was using it*.
- `breadcrumbs` — **the highest-value addition in this phase.** An ordered array of up to 20 entries, each `{ t, type, message, data? }` — `t` an ISO-8601 timestamp (dropped if unparseable, the breadcrumb is still kept), `type` one of `log`/`http`/`db`/`nav`/`auth`/`job` (anything else is stored as `log`), `message` max 200 chars, and an optional `data` object dropped if it doesn't serialize or exceeds 200 chars. If you send more than 20, the **20 nearest the error** are kept — the oldest go first, not the newest. A stack trace says *where* an error broke; breadcrumbs say what your process was doing in the seconds before, and for an integration failure the outbound call that just failed — a request logged as a breadcrumb — is usually the entire answer. Never rolled into anything long-lived; sent per event, read per event.
- `request` — `{ method, statusCode, durationMs, headers?, queryKeys? }`, the request the error happened during. `method` max 10 chars, `statusCode` an integer 100–599, `durationMs` an integer ≥ 0. **`headers` is an allow-list**, not a deny-list — only `content-type`, `accept`, `user-agent`, and `x-request-id` are ever stored; every other header name is dropped on arrival, silently, before it reaches the database. That is deliberate: a deny-list is one custom header away from a bearer token or session cookie landing in Sortd's store, and an allow-list makes that impossible regardless of what your app happens to send. Wire this up freely — there is no PII exposure to weigh against it.
- `queryKeys` — the **names** of the query-string parameters present on the request, never their values. A key like `force` or `page` is safe and useful context; the *value* of a query parameter is routinely where PII actually lives (an email in `?email=`, a token in `?access_token=`), so values are never accepted on this field at all — send the names only.
- `level` — `error` | `warning` | `fatal`. A coarser signal than `handled`, useful when your framework already classifies severity for you (e.g. a logger's own levels) and you want that classification to carry through rather than collapsing everything to `error`.
- `tags` — a flat string map, max 10 keys, key ≤ 40 chars, value ≤ 100 chars, e.g. `{ feature: 'billing', integration: 'xero' }`. Distinct from `context`: `context` is a looser bag for whatever's useful to a human reading the investigation, `tags` is capped and flat so it stays cheap to facet and filter on. Send either, both, or neither.
- `userKey` — an opaque, **stable** hash identifying the real person this happened to, max 100 chars — never an email or a raw user id. Different from `sessionKey`: `sessionKey` distinguishes "one dev hammering refresh" from "many real sessions," while `userKey` answers "how many distinct people hit this," which `sessionKey` alone cannot if one person has several sessions. Hash your own user id (e.g. `sha256(userId + salt)`) before sending — Sortd never needs, and must never receive, who that person actually is.

### Wiring it up

Save this as one module (a copy-paste reporter — there is no SDK) and call it from whichever hook your framework offers. It is built so that **reporting can never hurt the app reporting**: every request has a 3-second timeout, `reportError` is fire-and-forget (call it **without** `await` — it returns at once, never throws and never rejects), each event carries a unique `eventId` so a retry is counted once, a `429`/`503` is retried **once** after exactly the `Retry-After` Sortd sends, the reporter's own calls to Sortd are left out of breadcrumbs, logged URLs drop their query strings, and a fatal error is reported then the process exits after at most 2 seconds — it never keeps a crashed process alive. Capture as much as you have on hand at each call site; every extra field is one more thing the investigation agent doesn't have to guess:

```js
// sortd-reporter.js - CommonJS. (ESM: swap the two require lines for imports and the module.exports line for exports.)
const crypto = require('node:crypto');
const { AsyncLocalStorage } = require('node:async_hooks');

const SORTD_ERRORS_URL = 'https://support.example.com/api/errors';
const SORTD_ORIGIN = new URL(SORTD_ERRORS_URL).origin;
const REQUEST_TIMEOUT_MS = 3000;  // a slow or unreachable Sortd must never slow your app down
const FATAL_FLUSH_MS = 2000;      // how long a crashing process waits for its last report
const MAX_BREADCRUMBS = 20;

// Captured BEFORE the breadcrumb wrapper below replaces globalThis.fetch, so the reporter's own calls to Sortd
// never pass through it (and so never appear as breadcrumbs on the very error they report).
const rawFetch = globalThis.fetch;

// sha256 an identifier before sending it - Sortd must never receive a raw session id or user id.
// For userKey pass a salt (a constant secret of yours) so the hash cannot be reversed by guessing ids.
const hashId = (value, salt = '') =>
  value == null || value === '' ? undefined : crypto.createHash('sha256').update(salt + String(value)).digest('hex');

// Walks err.cause (and AggregateError.errors) outward-in, excluding err itself. Depth is capped server-side
// at 5; capping here too stops a cyclic or absurdly deep chain from bloating every report.
function causeChain(err, depth = 5) {
  const chain = [];
  let current = err?.cause ?? (Array.isArray(err?.errors) ? err.errors[0] : undefined);
  while (current && chain.length < depth) {
    chain.push({ name: current.name, message: current.message, stack: current.stack });
    current = current.cause ?? (Array.isArray(current.errors) ? current.errors[0] : undefined);
  }
  return chain.length ? chain : undefined;
}

// One breadcrumb trail PER REQUEST, not one per process: a single module-level array interleaves concurrent
// requests, attaching one user's activity to another user's error.
const breadcrumbStore = new AsyncLocalStorage();
const breadcrumbMiddleware = (req, res, next) => breadcrumbStore.run([], next);  // register FIRST

function pushBreadcrumb(type, message, data) {
  const trail = breadcrumbStore.getStore();
  if (!trail) return;  // outside a request (startup, a cron tick) - nothing to attach to
  trail.push({ t: new Date().toISOString(), type, message: String(message).slice(0, 200), data });
  if (trail.length > MAX_BREADCRUMBS) trail.shift();  // keep the ones nearest the error
}

// Logged URLs are origin + path only: a query string is where tokens and emails live.
function loggableUrl(input) {
  const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
  try {
    const u = new URL(raw);
    return { origin: u.origin, text: u.origin + u.pathname };
  } catch {
    return { origin: undefined, text: String(raw).split(/[?#]/)[0] };
  }
}

// Optional: record every outbound call as a breadcrumb. Calls to the Sortd origin are skipped.
globalThis.fetch = async function fetchWithBreadcrumb(input, init) {
  const { origin, text } = loggableUrl(input);
  if (origin === SORTD_ORIGIN) return rawFetch(input, init);
  const method = init?.method ?? input?.method ?? 'GET';
  try {
    const res = await rawFetch(input, init);
    pushBreadcrumb('http', method + ' ' + text + ' -> ' + res.status, { status: res.status });
    return res;
  } catch (err) {
    pushBreadcrumb('http', method + ' ' + text + ' -> ' + err.message);
    throw err;
  }
};

// unref: a pending retry delay must not keep the process alive.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref());
const inFlight = new Set();

// One attempt, plus at most ONE retry after a 429/503 - waiting exactly as long as Sortd's Retry-After says.
async function post(body) {
  const key = process.env.SORTD_INGEST_KEY || process.env.SORTD_API_KEY;  // ingest key preferred; the app key still works
  if (!key) return;
  for (let attempt = 0; attempt < 2; attempt++) {
    const res = await rawFetch(SORTD_ERRORS_URL, {
      method: 'POST',
      headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },
      body,
      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),  // covers the response body read too
    });
    await res.text();  // release the connection
    if ((res.status !== 429 && res.status !== 503) || attempt === 1) return;
    const header = res.headers.get('retry-after');
    const seconds = header === null ? NaN : Number(header);
    await sleep(Math.min(Number.isFinite(seconds) && seconds >= 0 ? seconds : 1, 60) * 1000);
  }
}

// Fire-and-forget: call it WITHOUT await. It returns immediately, never throws, and never rejects.
function reportError(err, extra = {}) {
  try {
    const e = err instanceof Error ? err : new Error(String(err));
    const body = JSON.stringify({
      events: [{
        eventId: crypto.randomUUID(),            // a retried request is counted once, not twice
        name: e.name,
        message: e.message,
        stack: e.stack,
        cause: causeChain(e),                     // strongly recommended - see field notes
        release: process.env.RELEASE_SHA,         // optional - whatever you tag deploys with
        environment: process.env.NODE_ENV,        // optional but recommended
        route: extra.route,                       // optional - the request path
        url: extra.url,                           // optional - query string is stripped server-side
        handled: extra.handled,                   // optional but recommended - true = caught-and-continued
        level: extra.level,                       // optional - error | warning | fatal
        sessionKey: extra.sessionKey,             // optional - already hashed, see hashId
        userKey: extra.userKey,                   // optional - already hashed, see hashId
        context: extra.context,                   // optional - small structured tags
        tags: extra.tags,                         // optional - capped flat tags
        request: extra.request,                   // optional - { method, statusCode, durationMs, headers?, queryKeys? }
        breadcrumbs: breadcrumbStore.getStore()?.slice(),
      }],
    });
    const p = post(body).catch(() => {}).finally(() => inFlight.delete(p));  // reporting failures never reach the caller
    inFlight.add(p);
    return p;
  } catch {
    return Promise.resolve();
  }
}

// Waits for in-flight reports, but never longer than ms.
function flush(ms = FATAL_FLUSH_MS) {
  let timer;
  return Promise.race([
    Promise.allSettled([...inFlight]),
    new Promise((resolve) => { timer = setTimeout(resolve, ms); }),
  ]).finally(() => clearTimeout(timer));
}

// After an uncaught exception the process is in an unknown state. Registering ANY handler stops Node crashing,
// so this one reports, waits at most FATAL_FLUSH_MS for the report to leave, then exits non-zero as Node would.
let exiting = false;
function crash(err) {
  if (exiting) return;
  exiting = true;
  console.error(err);
  reportError(err, { handled: false, level: 'fatal' });
  flush().finally(() => process.exit(1));
}
function installFatalHandlers() {
  process.on('uncaughtException', crash);
  process.on('unhandledRejection', crash);
}

module.exports = { reportError, flush, installFatalHandlers, breadcrumbMiddleware, pushBreadcrumb, hashId };
```

Credential: use an **ingest key** (`sk_ingest_…`, create one under **Settings → Alerts → Ingest keys**, keep it in `SORTD_INGEST_KEY`). It is write-only for this one endpoint, revocable and rotatable without touching anything else. The legacy app key (`SORTD_API_KEY`, `sk_app_…`) is still accepted on `POST /api/errors` and the reporter above falls back to it, but an ingest key is the preferred credential — it cannot read or create tickets if it leaks.

Call sites, pick whichever apply:

- **Node/Express** — a global error-handling middleware (registered last, after all routes) reports `handled: true` (your app caught it, sent a 500 response, and kept running); `installFatalHandlers()` covers the process-level safety nets (`handled: false`, `level: 'fatal'`) and exits after reporting. Register `breadcrumbMiddleware` FIRST so every request gets its own trail, and track `startedAt` at the top of the request so the middleware can report `durationMs`:
  ```js
  const { reportError, installFatalHandlers, breadcrumbMiddleware, hashId } = require('./sortd-reporter');

  app.use(breadcrumbMiddleware);  // FIRST - one breadcrumb trail per request
  app.use((req, res, next) => { req.startedAt = Date.now(); next(); });

  // ...your routes...

  app.use((err, req, res, next) => {
    reportError(err, {  // no await: reporting must never sit in the request path
      route: req.path,
      url: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
      // Hash both - never send a raw session id (a live credential) or a raw user id.
      sessionKey: hashId(req.session?.id),
      userKey: hashId(req.user?.id, process.env.SORTD_USER_SALT),
      handled: true,
      request: {
        method: req.method,
        statusCode: res.statusCode >= 400 ? res.statusCode : 500,
        durationMs: Date.now() - req.startedAt,
        headers: { 'content-type': req.get('content-type') },
        queryKeys: Object.keys(req.query ?? {}),
      },
    });
    next(err);
  });

  installFatalHandlers();  // uncaughtException / unhandledRejection: report, wait <= 2s, exit(1)
  ```
- **Next.js (App Router)** — `instrumentation.ts`'s `onRequestError` hook, or a `try/catch` around route handler bodies for errors you want reported without failing the response.
- **FastAPI / other non-Node backends** — the same JSON shape over HTTP; there is no SDK, only the endpoint contract above.

`202 Accepted`:

```json
{
  "accepted": 2, "ignored": 1, "rateLimited": 0, "duplicates": 0, "failed": 0,
  "fingerprints": ["a1b2c3d4e5f6a7b8"],
  "results": [
    { "status": "accepted", "fingerprint": "a1b2c3d4e5f6a7b8" },
    { "status": "ignored" }
  ]
}
```

`accepted` is how many events were taken in; `ignored` is how many of those were dropped as known noise (`ResizeObserver loop…`, `Script error.`, `Non-Error promise rejection captured`, or an event carrying neither a message nor a stack). A persistently high `ignored` count means the reporter is forwarding events that will never be grouped or investigated.

The response also carries `rateLimited` (events over the per-error limit — see below), `duplicates` (events skipped because their `eventId` was already seen), `failed`, and a `results` array aligned with the `events` you sent — each entry's `status` is `accepted`, `ignored`, `duplicate`, `rate_limited`, `failed` or `invalid`. A batch where only some events failed is still `202`: resend only the `failed` ones (with their `eventId`s, so nothing is double-counted), never the whole batch.

`fingerprints` echoes the dedup key(s) Sortd computed for the submitted event(s). Treat each as an **opaque string of at most 32 characters** — never assume a length or format (the example above is illustrative); just pass it back unchanged. If the app also lets end users raise a ticket directly (see above), pass recent fingerprints seen around the same time as `errorFingerprints` on that ticket-creation call — Sortd matches them against any in-flight or already-fixed error group and tells the user "we already found this" instead of starting a duplicate investigation from scratch.

Errors: `401` if the key doesn't resolve (unknown, revoked, or tampered — all answer identically); `403` if the org is suspended; `413` if the request body exceeds 256KB (raised from 64KB now that breadcrumbs and request context routinely fill 20 events); `429` (with `Retry-After: 60`) if the per-app rate limit is hit (default 600/min, `ERROR_INGEST_RATE_PER_MIN`); `503` (with `Retry-After`) only when **every** event in the request failed to store — nothing was recorded, so retry the whole request after the delay.

**Per-error limit.** Any single error (one fingerprint) is fully recorded up to a per-fingerprint limit per minute per app (the limit is configurable by Sortd; treat it as "a few dozen a minute", not a fixed number). Beyond that its events are counted on the group but not stored individually, and come back as `rateLimited` in the `202` — a non-zero `rateLimited` is the signal to back off or sample that one error client-side. It never blocks other errors, and only events under the limit count toward the app-wide limit above.

**A `204` with no body is not an error and must not be retried.** The `X-Sortd-Errors` response header says why: `disabled` means error tracking is switched off for this app (enable it in **Settings → Alerts → Track errors**; Sortd counts how many events arrived while it was off and shows that in the portal), `platform-disabled` means Sortd has paused error ingest platform-wide. In both cases nothing was stored; continuing to send is harmless, and there is nothing to fix on your end for `platform-disabled`.

### Error Alert Webhook Events

When an app's error alert rules (portal: Settings → Alerts) match, Sortd sends these events to `SORTD_WEBHOOK_PATH` — same signature verification as every other webhook in this guide. They are off the ingest path: nothing here changes what `POST /api/errors` returns.

- `error.new_group` — an error group appeared that has not been seen in the rule's environments before (default: production only, from the first occurrence — a bug that was only ever seen in staging is still new when it first reaches production). Sent at most once per group.
- `error.regression` — a group that was resolved came back (the same release-aware rule the portal applies). Carries `regression: { resolvedInRelease?, regressedInRelease? }`. Sent at most once per regression, at most once per cooldown (default 24h), and only while the regression is recent.
- `error.spike` — a group's events this hour far exceed its trailing 7-day hourly median (off by default). Carries `spike: { currentHour, baseline, factor }`.

An individual alert (every `error.new_group` / `error.regression` / `error.spike` that is not a digest) carries `groupId`, `name`, `message` (scrubbed, at most 200 characters), `topFrame?` (the top in-app stack frame as `function (file:line)`, scrubbed, at most 160 characters), `route?`, `environment?`, `release?`, `count` and `url` (a portal link to the issue). Nothing in an alert is model-written.

**Digests.** When more than three `error.new_group` (or, separately, more than three `error.regression`) alerts for one app land inside a five-minute window, the first three are sent individually and the rest are folded into ONE digest sent shortly after the window ends, using the same event name. A digest has **no `groupId`**: it carries `"digest": true`, `count` (how many groups it covers — only the ones NOT already sent individually), `top` (up to five `{ groupId, name, route? }`) and `url` (the portal error list). Check `digest` before reading `groupId`.

Delivery is at-most-once. Key your handler on `(event, groupId)` for an individual alert; a digest has no `groupId`, so treat it as informational.

### Runtime Error Reporting Verification

Verify:

- Error tracking is on for the app (**Settings → Alerts → Track errors**) before expecting anything other than `204`. If you get a `204` with no body, read the `X-Sortd-Errors` header: `disabled` means switch tracking on for the app; `platform-disabled` means Sortd has paused ingest and it is not something to fix in this repo. A `204` is never retried.
- `POST /api/errors` with a minimal event (`message` only) returns `202` whose body has `accepted`, `ignored`, `rateLimited`, `duplicates`, `failed`, a `results` array aligned with the events you sent, and a non-empty `fingerprints` array. Expect `rateLimited`, `duplicates` and `failed` to be `0` in normal use; resend only `failed` events.
- A `503` (every event failed to store) or `429` carries a `Retry-After` header (seconds); the reporter above waits exactly that long and retries once, never more.
- A `401` means the ingest key is wrong or revoked (or, for the legacy key, rotated) — the reporter drops the event silently, so confirm the key by hand with `curl` rather than by waiting for an error to appear.
- With Sortd unreachable (point the reporter at a port nothing listens on) the app keeps serving at normal speed — a stalled Sortd costs at most the 3-second timeout, off the request path.
- An uncaught exception in a throwaway script that calls `installFatalHandlers()` exits the process non-zero within about 2 seconds, and the event arrives with `handled: false` and `level: "fatal"`.
- Breadcrumbs for an error that follows an outbound call show that call's origin and path only (no query string), and no entry for the `/api/errors` request itself.
- The same error reported repeatedly from a single `sessionKey` does not, by itself, spawn a bug task — distinct sources matter, not raw volume.
- Sending an event with a `breadcrumbs` array populated by the ring buffer above results in those entries appearing on the error group's detail view in the portal, in order, oldest first.
- The error group appears in the portal (My Apps → [App] → Runtime Errors) with the correct occurrence count and last-seen time.
- That page's header shows **Last error received** — the ingest heartbeat. If it says no errors have arrived, the reporter is not reaching Sortd (wrong key, wrong base URL, or the call is being swallowed by its own try/catch); if it is stale, the reporter stopped. When a group is listed but no investigation started, the row states why (below the occurrence threshold, too few distinct sources, daily cap reached, code-fix analysis off).

## Service Monitoring (Optional)

Service Monitoring lets the app's own backend declare external dependencies — a payments API, a status page, a nightly batch job — that Sortd checks on a schedule (or listens for) and alerts the team about when something goes wrong. It is entirely optional and off by default: nothing is probed and no heartbeat is expected until the app creates its first monitor.

There are five monitor types:

- `http` — Sortd polls a URL on an interval and judges success by status code, optionally the response body (text, JSON values) and optionally response time.
- `vendor_feed` — Sortd polls a statuspage.io-format `summary.json` feed and mirrors its reported status.
- `heartbeat` — the reverse direction: **the app pings Sortd**, not the other way around. A missed ping past its interval + grace period is reported as down. Use this for cron jobs, queue consumers, and anything that runs periodically rather than serving requests.
- `tcp` — Sortd opens a TCP connection to a host and port (a database, a mail or SSH server, any custom service) and judges success by whether it connects, optionally also checking the first bytes the server sends.
- `dns` — Sortd resolves one record type for a host name and judges success by whether the record exists, optionally also checking its value.

### `PUT /api/monitors/{key}`

Idempotent upsert, keyed by a stable string `key` you choose (lowercase, alphanumeric with hyphens/underscores). Safe to call on every deploy — re-declaring the same monitor with the same config is a no-op; changing its definition updates it in place. `key` is part of the URL, not the body.

`http` example:

```http
PUT https://support.example.com/api/monitors/payments-api
Authorization: Bearer {SORTD_API_KEY}
Content-Type: application/json

{
  "type": "http",
  "name": "Payments API",
  "intervalSec": 60,
  "http": {
    "url": "https://payments.example.com/health",
    "method": "GET",
    "expectedStatus": [200],
    "authHeaderName": "Authorization",
    "authHeaderValue": "Bearer live_key_..."
  }
}
```

`vendor_feed` example:

```http
PUT https://support.example.com/api/monitors/stripe
Authorization: Bearer {SORTD_API_KEY}
Content-Type: application/json

{
  "type": "vendor_feed",
  "name": "Stripe",
  "intervalSec": 300,
  "vendorFeed": { "baseUrl": "https://status.stripe.com" }
}
```

**Scope it to what you actually use.** Some vendors publish a single status feed that covers every region or product line and rolls them all into one overall indicator — Xero is a real example: its feed reports "United Kingdom - Regional Products" and "New Zealand - Regional Products" as separate components, but `baseUrl` alone reads the *overall* indicator, which is the worst of every component. Leave that unscoped and a UK-only outage will show up as your integration being degraded even if you only ever call the NZ org. Before creating a `vendor_feed` monitor, fetch `{baseUrl}/api/v2/summary.json` yourself and read `components[].name` to see what the vendor actually breaks its status out by, then pass the ones you depend on:

```http
PUT https://support.example.com/api/monitors/xero
Authorization: Bearer {SORTD_API_KEY}
Content-Type: application/json

{
  "type": "vendor_feed",
  "name": "Xero",
  "intervalSec": 300,
  "vendorFeed": {
    "baseUrl": "https://status.xero.com",
    "components": ["Core Xero Products", "New Zealand - Regional Products"]
  }
}
```

Unset `components` only when the vendor's feed genuinely has no sub-breakdown (a single-product vendor like Stripe, above) — not by default.

`heartbeat` example — declares the monitor and its expected cadence up front; the actual pings happen against a separate endpoint below:

```http
PUT https://support.example.com/api/monitors/nightly-billing-sync
Authorization: Bearer {SORTD_API_KEY}
Content-Type: application/json

{
  "type": "heartbeat",
  "name": "Nightly Billing Sync",
  "intervalSec": 86400,
  "heartbeat": { "graceSec": 1800 }
}
```

`201 Created` the first time a `key` is used, `200 OK` on every subsequent call that updates it — both return `{ "monitor": { ... } }`.

**Re-running a `PUT` never overwrites notification routing or public-status settings configured in the portal.** Those two settings — who gets alerted, and whether the monitor appears on a public status page — are portal-owned and intentionally outside this contract; only the probe definition itself (name, interval, thresholds, target) is under code control. A team can safely mute a noisy monitor or add it to their public status page without a future deploy silently reverting that choice.

**What a `PUT` replaces and what it keeps.** The fields you send are the whole definition: anything you leave out is **cleared**, not kept (drop `bodyKeyword`, `expectedStatus`, `authHeaderName` or `vendorFeed.components` from the body and the next `PUT` removes it). Sortd's own bookkeeping is **never** overwritten by a `PUT`: a heartbeat's last-ping time and its up/down history, and the stored `http.authHeaderValue`. That credential is write-only — send it to set or rotate it, **omit it to keep the stored one** (so you do not have to re-send the secret on every deploy), and send `""` or `null` to delete it. **A stored credential only stays attached to the host it was entered for:** a `PUT` that changes `http.url` to a different origin (scheme, host or port), or renames `authHeaderName`, while the stored `authHeaderValue` or any stored header value would stay attached is refused with `422 { "error": "credentials_require_reentry" }` and changes nothing. Send the new value(s) in that same request (or `""` / `headers: []` to drop them). A path or query change on the same origin keeps them. Changing a heartbeat's `intervalSec` or `graceSec` pushes its next deadline a full interval + grace out from the `PUT`, so a redeploy that retunes the schedule does not page you for a job that is running fine. A `PUT` that changes nothing changes nothing.

**Optional false-alarm controls.** Two numeric fields can be added to the body: `confirmRetries` (`http` only, 0-3; default `1` for a monitor created by a `PUT`) re-probes a failing endpoint a few seconds later, before an incident opens, so a one-off network blip does not page anyone; and `recoveryThreshold` (1-10; default `1`) is how many consecutive successful checks an `http`/`vendor_feed` monitor needs before it is declared recovered (a heartbeat ping always recovers immediately, so `recoveryThreshold` must be `1` or omitted there). **Leave either out and an existing monitor keeps its stored value**; they have no "clear" meaning.

**Optional `http` checks, headers and redirects.** All of these go inside the `http` object; every one is optional and, like `bodyKeyword`, **a field you leave out of a `PUT` is cleared** (except `headers`, below):

- `latencyThresholdMs` (100-30000): a response that is otherwise healthy but slower than this is reported as **degraded** rather than down (it alerts as `monitor.degraded`, then recovers on a fast check).
- `bodyNotContains` (text, up to 200 characters): the response body must **not** contain it. (`bodyKeyword`, `bodyNotContains` and `jsonAssertions` cannot be combined with `"method": "HEAD"`: a HEAD response has no body, so the request is refused with 400.)
- `jsonAssertions` (up to 5): `[{ "path": "data.status", "op": "eq", "value": "ok" }]`. `op` is `eq`, `exists` (no `value`), `contains`, `gt` or `lt`. A path is dot / bracket notation only (`data.items[0].status`, `$['odd-key']`): no wildcards, filters or functions, and the keys `__proto__`, `constructor` and `prototype` are refused. Body checks look at the **first 64 KB** of the response only, and a failure reports the path and operator, never the value or the body.
- `headers` (up to 10): `[{ "name": "X-Api-Key", "value": "..." }]`. Header **values are encrypted at rest and never returned by any endpoint or shown in the portal** (`GET` returns the names only); audit entries record names only. Names must be valid HTTP tokens, and `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, `Upgrade`, `TE`, `Trailer`, `Expect`, `Keep-Alive` and `Proxy-*` are refused, as are line breaks in a name or value. Values may contain tab, printable ASCII and Latin-1 characters only (anything above U+00FF is refused: the HTTP client cannot send it). Every entry needs its value on a `PUT`, and a value you send is always re-encrypted and written (the audit entry shows `Name: [redacted]`), so re-sending the same list is harmless but is recorded as an update. **Omit `headers` to keep the stored ones, send `[]` to remove them all.** The single `authHeaderName` / `authHeaderValue` pair still works and is independent of this list.
- `requestBody` (up to 10 KB, `POST` only) with `contentType` (`application/json` by default, or `text/plain` / `application/x-www-form-urlencoded`). The body is stored as plain text, **not** encrypted: put credentials in a header instead.
- `followRedirects` (default `false`: a 3xx is judged like any other status) with `maxRedirects` (1-5, default 5). When on, each hop is checked against the same address rules as the original URL, the total time stays within `timeoutMs`, headers and credentials are **not** sent to a different origin, and a request that carries a body is not re-sent to another origin.
- `sslExpiryWarnDays` (1-90, `https` URLs only): also checks the server's TLS certificate. A certificate with fewer days left than this reports the monitor as **degraded** ("TLS certificate expires in N days"); an expired, self-signed or otherwise untrusted certificate reports it **down**. The observed expiry is cached for 6 hours per host and port, so TLS is not re-checked on every interval, and the monitor's `lastCertExpiresAt` shows it in the portal and in `GET`. Leave it out (or send `0`) to turn the check off.

**`tcp` and `dns` monitors.** Both take the same `intervalSec` values as `http` (60, 300 or 900), plus the optional `timeoutMs`, `failureThreshold` and `recoveryThreshold`; neither has `confirmRetries`. Every message they raise is a fixed sentence: Sortd never stores or returns a server banner, a DNS answer or a resolved address.

- `tcp`: `{ "type": "tcp", "name": "Postgres", "intervalSec": 60, "tcp": { "host": "db.example.com", "port": 5432 } }`. `host` is a public host name or public IP address (1-65535 for `port`). Sortd resolves the name itself and refuses to connect when **any** address it resolves to is private, loopback, link-local or otherwise reserved, so internal services cannot be probed. Optional `expectBanner` (printable ASCII, up to 100 characters) must appear in the first 512 bytes the server volunteers (SSH, SMTP and FTP do; a database does not); it is a yes/no check and Sortd sends nothing to the server. Leave it out of a `PUT` to clear it.
- `dns`: `{ "type": "dns", "name": "Apex record", "intervalSec": 300, "dns": { "hostname": "www.example.com", "recordType": "A", "expectedValue": "203.0.113.10", "expectedMatch": "equals" } }`. `recordType` is `A`, `AAAA`, `CNAME`, `MX` (the mail host names are compared) or `TXT`. Without `expectedValue` the monitor is up when the record exists. With it, `expectedMatch` is `equals` (default) or `contains` against any one record (host names compare case-insensitively without the trailing dot; `TXT` values compare exactly). `hostname` must be a fully qualified public name: IP addresses, single-label names, `.local`, `.internal`, `.localhost` and numeric-only names are refused with `400`.

A monitor's `type` is fixed when it is created: a `PUT` that names a different `type` for an existing `key` is refused with `409 { "error": "type_is_immutable" }` and nothing is changed. To change the type, `DELETE` the monitor and `PUT` it again.

Every `PUT` that creates or changes a monitor and every `DELETE` is recorded in the app's audit log.

Errors: `400` invalid body (bad `key`, missing required fields, out-of-range interval); `401`/`403` same as every other endpoint in this guide; `409 { "error": "source_conflict" }` if a human already created a monitor with this `key` from the portal — Sortd will not silently take over a portal-managed monitor; `409 { "error": "type_is_immutable" }` if `key` already exists with a different `type`; `422 { "error": "cap_reached" }` if the app is already at its monitor limit (does not apply to updating an existing monitor; if several new monitors are created at the same instant right at the limit, a request can get this even though a slot looks free — repeat the `PUT`); `422 { "error": "invalid_probe_url" }` if the target URL fails Sortd's SSRF checks (no internal/private addresses).

### `GET /api/monitors`

Lists every monitor declared for the app, most recent `key` first alphabetically:

```http
GET https://support.example.com/api/monitors
Authorization: Bearer {SORTD_API_KEY}
```

Returns `{ "monitors": [ { "key": "payments-api", "status": "up", ... } ] }`. `status` is one of `pending`, `up`, `degraded`, `down`, `paused` or `maintenance` (the monitor is inside a scheduled maintenance window set up in the portal: checks keep running but alerts are paused and the time is left out of uptime). While it is, the monitor also carries `"maintenance": { "until": "2026-05-12T14:00:00Z", "windowId": "..." }` (otherwise `null`). Treat an unknown `status` as informational, not as a failure.

### `GET /api/monitors/{key}` and `DELETE /api/monitors/{key}`

`GET` returns one monitor's current status and config (`404` if no monitor exists for that `key`). `DELETE` removes a monitor Sortd created (`204` on success, `404` if absent, `409 { "error": "source_conflict" }` if it was created from the portal instead — delete those there).

### `POST /api/monitors/heartbeat/{key}`

The dead-man's-switch pattern: call this every time the job runs. If a ping doesn't arrive within `intervalSec + graceSec` of the last one, the monitor goes down and the team is alerted. The first ping ever received for a given `key` auto-creates the monitor — calling `PUT /api/monitors/{key}` first is optional, useful mainly for naming it or setting a non-default interval/grace before the job's first real run.

```bash
# At the end of a nightly cron job:
0 2 * * * /usr/local/bin/run-billing-sync.sh && curl -fsS -X POST   "https://support.example.com/api/monitors/heartbeat/nightly-billing-sync"   -H "Authorization: Bearer $SORTD_API_KEY"
```

Chain it with `&&` after the job itself (as above) so a failing job never sends a ping — the absence of a ping is what Sortd needs to see. Optional JSON body `{ "intervalSec"?, "graceSec"? }` sets the cadence on first ping only:

- `intervalSec` — how often you expect to ping (seconds). This is your own job's period, not a Sortd polling cadence.
- `graceSec` — extra time allowed past `intervalSec` before a missed ping counts as down, to absorb normal run-time jitter.

`200`/`201` on success (`201` only for the first ping that creates the monitor). `204` with no body means Service Monitoring is currently disabled for the app or platform-wide — keep pinging on schedule; nothing is being alerted on in the meantime, and there is nothing to fix on your end. `409 { "error": "type_conflict" }` if `{key}` already names a non-heartbeat monitor. `422 { "error": "cap_reached" }` if pinging would create a new monitor past the app's limit.

**Run signals: `/start` and `/fail`.** The same endpoint takes two optional suffixes, so a job can say it began and say it failed instead of only going quiet:

- `POST /api/monitors/heartbeat/{key}/start` — a run began. It changes no status and moves no deadline; it only remembers the time so the next success can report how long the run took. Run durations are kept as hourly aggregates (average and maximum), never as one row per run.
- `POST /api/monitors/heartbeat/{key}` — the run succeeded (as above). If a `/start` came first, the elapsed time is recorded.
- `POST /api/monitors/heartbeat/{key}/fail?exit=<int>` — the run failed. This is an immediate failed check: it counts toward the monitor's `failureThreshold` exactly like a missed ping would and opens an incident when the threshold is reached, with the cause `job reported failure (exit N)`. `exit` is optional and ignored unless it is a small integer. The next successful ping resolves the incident. A failure is applied **at most once per `intervalSec` window**: repeated `/fail` calls inside that window only refresh the last-failure time, so a looping job cannot flood the timeline, and `failureThreshold` counts failing runs that fall in different windows.

`/start` and `/fail` never create a monitor: they return `404 { "error": "not_found" }` for a `key` that does not exist yet (send one success ping, or `PUT` the monitor, first). A run that starts but never reports success or failure is not a special case: the missing ping is reported as down at the normal deadline (`intervalSec + graceSec` after the last success).

### Tokenised ping URLs (for cron systems that can only call a URL)

Some schedulers (hosted cron, uptime-style "healthcheck" fields, CI schedules) cannot send an `Authorization` header. A heartbeat monitor can instead have a **ping URL** whose secret token is the credential. Generate it in the portal: open the heartbeat monitor and choose **Generate ping URL** (an app admin action). The full URL is shown **once** — Sortd stores only a hash of the token, so it can never be displayed again. **Rotate** issues a new URL and the old one stops working immediately. Treat the URL as a secret: anyone holding it can ping that one monitor, and nothing else.

```bash
# Success, start and failure signals (GET, HEAD and POST are all accepted):
curl -fsS -m 10 --retry 3 "https://support.example.com/api/monitors/ping/<token>"
curl -fsS -m 10 "https://support.example.com/api/monitors/ping/<token>/start"
curl -fsS -m 10 "https://support.example.com/api/monitors/ping/<token>/fail?exit=2"

# A cron entry, pinging only when the job succeeds:
0 2 * * * /usr/local/bin/run-billing-sync.sh && curl -fsS -m 10 --retry 3 "https://support.example.com/api/monitors/ping/<token>" > /dev/null
```

The ping URL behaves like the Bearer endpoint above, with these differences: it only ever addresses the monitor it was generated for and **never creates a monitor**; a wrong, malformed or rotated-away token always returns the same `404 { "error": "not_found" }`; and it is rate limited (`429` with `Retry-After`): a runaway loop on one URL is capped per client, and a client that keeps presenting unknown URLs is capped by its number of misses, so many working URLs behind one shared egress address do not throttle each other. `204` still means Service Monitoring is currently disabled — keep pinging. **Link previews are ignored:** a `GET` or `HEAD` from a known link unfurler or crawler (Slack, Discord, Twitter/X, Facebook, LinkedIn, WhatsApp, Telegram, Google, Bing, Apple) returns `200` and changes nothing, so pasting the URL into a chat, ticket or document cannot make a dead job look alive. The monitor's configuration (`GET /api/monitors/{key}`) reports only `hasPingToken` — never the token.

### Webhook Events

Sortd sends these additional webhook events to `SORTD_WEBHOOK_PATH` as a monitor's status changes — same signature verification as every other webhook in this guide:

- `monitor.down` — the monitor crossed its failure threshold.
- `monitor.degraded` — a partial/degraded state was reported: a `vendor_feed` reporting a partial outage, or an `http` monitor whose response was slower than its `latencyThresholdMs`.
- `monitor.recovered` — the monitor returned to `up` after being down or degraded.
- `monitor.reminder` — an incident is still open. Sent every `reminderEveryMin` minutes, only for a monitor whose reminder interval was set in the portal (off by default); acknowledging the incident or snoozing it mutes it, and it is paused during a maintenance window. Carries `reminder: { count, downForMs }`.
- `monitor.acknowledged` — a team member acknowledged an open incident in the portal. Carries `acknowledgedBy: { name?, email }` and an optional `note`.
- `monitor.maintenance_started` / `monitor.maintenance_ended` — a scheduled maintenance window began / ended (`cancelled: true` when it was cancelled early). Carries `window: { id, title, startsAt, endsAt, publicMessage?, showOnStatusPage }` and the `monitors` it covers (up to 50, `{ id, key, name }`). A recurring window sends one pair per occurrence.

```json
{
  "event": "monitor.down",
  "monitor": { "id": "6650...", "key": "payments-api", "name": "Payments API", "type": "http" },
  "incident": {
    "id": "6651...",
    "severity": "down",
    "startedAt": "2026-05-12T12:00:00Z",
    "resolvedAt": null,
    "durationMs": null,
    "lastError": "expected status 200, got 503"
  }
}
```

`monitor.recovered` carries the same shape with `incident.resolvedAt` and `incident.durationMs` populated.

A monitor that keeps failing and recovering within its flap window (10 minutes by default, set in the portal) is **not** announced every cycle: the first re-failure sends one `monitor.down` carrying `"flapping": { "groupId": "...", "reopenCount": 1 }`, further re-failures inside that window are not re-sent, their recoveries are not sent, and once the monitor has stayed up for the whole window a single `monitor.recovered` follows with `"stable": true`. An escalation from degraded to down is always sent.

Webhook payloads for `monitor.down` and `monitor.recovered` may carry extra flags: `afterMaintenance: true` (with `maintenance: { windowId, title }`: the incident began before a maintenance window and is still open now that it ended; sent once, together with `stillDown: true`), `duringMaintenance: true` on `monitor.recovered` (the recovery happened inside a window; it is always sent, an announced outage never ends silently), `flapping`, `stillDown: true` (a repeat failure still unresolved after the flap window; sent once), and `stable: true` (the one all-clear after a flapping monitor stayed healthy). A stable notice reuses the original (root) incident's id, so do NOT dedupe on `incident.id` alone. Key on `(incident.id, event, stable)`.

### Service Monitoring Verification

Verify:

- `PUT /api/monitors/{key}` for a real dependency returns `201`, and calling it again with the same body returns `200` and does not create a second monitor.
- The monitor appears in the portal (My Apps → [App] → Monitoring) with a `pending` status until its first check runs.
- For a heartbeat monitor: the first `POST /api/monitors/heartbeat/{key}` returns `201`; running the job's real command and pinging afterward on schedule keeps it `up`; withholding a ping past `intervalSec + graceSec` flips it to `down` and fires `monitor.down`.
- Muting notifications or enabling the public status page entry for a monitor in the portal survives a subsequent `PUT` with the same deploy-time config.

## Announcements (Optional)

Announcements are custom banner messages a tenant's own team authors in the Sortd portal — a downtime update, a planned-maintenance notice, or any other custom notice. If this app renders the embed widget, it already shows them automatically. This section is for a client app that does **not** use the embed widget and wants to render the banner itself.

### `GET /api/announcements`

Returns every announcement currently live for this app (published, and inside its optional start/end window):

```http
GET https://support.example.com/api/announcements
Authorization: Bearer {SORTD_API_KEY}
```

```json
{
  "announcements": [
    {
      "id": "6650...",
      "title": "Planned maintenance tonight",
      "body": "We're upgrading our database between 11pm and 1am UTC. Expect brief interruptions.",
      "level": "maintenance",
      "startsAt": "2026-09-22T23:00:00.000Z",
      "endsAt": "2026-09-23T01:00:00.000Z",
      "dismissible": true,
      "linkUrl": null,
      "linkLabel": null,
      "surfaces": { "embed": true, "statusPage": true, "helpCenter": true },
      "publishedAt": "2026-09-22T10:00:00.000Z",
      "updatedAt": "2026-09-22T10:00:00.000Z"
    }
  ]
}
```

An empty array means there is currently no banner to show — treat it exactly like "no announcements" and remove any banner you're currently displaying. Poll on whatever cadence fits your app (once on load plus every few minutes is enough); an announcement's own `startsAt`/`endsAt` window is what makes it appear or disappear, not a push from Sortd.

**Render `title` and `body` as plain text.** Both are tenant-authored strings, never markdown or HTML — never inject them with `dangerouslySetInnerHTML` or an equivalent. Use `level` (`info`/`warning`/`critical`/`maintenance`) to color the banner, `dismissible` to decide whether to show a close control, and `linkUrl`/`linkLabel` for an optional call-to-action link.

`200` always, even with zero announcements — an empty list is not an error. `401`/`403` same as every other endpoint in this guide. `429` if you're polling too aggressively, with `Retry-After`.

### Webhook Events

Sortd sends three announcement lifecycle events to `SORTD_WEBHOOK_PATH` — same signature verification as every other webhook in this guide:

- `announcement.published` — a new announcement went live, or an existing one was (re)published.
- `announcement.updated` — a live announcement's content changed.
- `announcement.ended` — a live announcement's window closed on its own (`endsAt` passed). This is the one case the `GET` endpoint above can't tell you about after the fact, since an expired announcement simply stops appearing in its response.

```json
{
  "event": "announcement.ended",
  "announcement": {
    "id": "6650...",
    "title": "Planned maintenance tonight",
    "body": "We're upgrading our database between 11pm and 1am UTC. Expect brief interruptions.",
    "level": "maintenance",
    "startsAt": "2026-09-22T23:00:00.000Z",
    "endsAt": "2026-09-23T01:00:00.000Z",
    "dismissible": true,
    "linkUrl": null,
    "linkLabel": null,
    "surfaces": { "embed": true, "statusPage": true, "helpCenter": true },
    "publishedAt": "2026-09-22T10:00:00.000Z",
    "updatedAt": "2026-09-23T01:00:00.000Z"
  }
}
```

`announcement.published` and `announcement.updated` carry the same `announcement` shape.

### Announcements Verification

Verify:

- `GET /api/announcements` returns a currently-published announcement inside its window, and an empty array once it's archived or its window has passed.
- `title`/`body` render as plain text in your UI — a banner containing `<b>` or similar shows the literal characters, not bold text.
- Publishing an announcement in the portal fires `announcement.published` to your webhook endpoint; editing it fires `announcement.updated`; letting its `endsAt` pass without touching it fires `announcement.ended` within a few minutes.
- Dismissing a dismissible announcement in your own UI does not need to call Sortd back — dismissal state is entirely client-side.

## Fix Sortd Bug Tasks From Your Editor (Optional)

This is the reverse direction from **MCP Integration** above, and the two are easy to confuse:

| | Who calls whom | Purpose |
|---|---|---|
| MCP Integration (above) | **Sortd calls your app** | The support agent looks up live data while answering a ticket. |
| This section | **Your coding assistant calls Sortd** | Your assistant pulls bugs Sortd already investigated and fixes them in your repo. |

They are independent. You can use this without setting up MCP Integration at all.

When a bug reaches Sortd — from a support conversation, a directly-raised ticket, or runtime error reporting above — Sortd investigates it against your connected repository and produces a **bug task**: a root-cause hypothesis, the specific files and line ranges it believes are at fault, supporting evidence, and often a proposed patch. Until now that lived in the portal for a human to read. Sortd's MCP server hands the same record straight to the coding assistant that can actually edit the code — and gives it access to the underlying support **tickets** too: reading the reporter's own thread, replying to them, and moving a ticket through its lifecycle, all without leaving the editor.

This is a **developer workstation tool**. It is not part of the client app's runtime, ships no code into the app, and requires no changes to the app's codebase.

### Claude Code plugin (recommended)

Two commands, no API key to generate or paste:

```bash
claude plugin marketplace add https://support.example.com/marketplace.json
claude plugin install sortd
```

The first tool call opens a browser consent screen against this platform's own OAuth 2.1
authorization server; Claude Code holds the resulting token for future sessions. This gets you
everything the app-key setup below does — same behavior, documented in the Tools /
Typical Loop / Guardrails / Verification sections below — **plus** more platform tools (creating
apps, connecting GitHub, generating and reviewing the knowledge base, managing monitors, inviting
teammates; all at one endpoint, `https://support.example.com/mcp`) and four Claude Code skills (`/sortd:onboard`, `/sortd:integrate`, `/sortd:fix-bug`,
`/sortd:triage`) that already know how to drive all of it. The one difference: every tool call takes
an explicit `appId` (one OAuth connection reaches every app you have access to) instead of being
scoped by which key you used.

Skip the rest of this section unless you specifically want a static, app-scoped API key instead of
OAuth — e.g. for CI, a headless environment, or a single-app integration where minting and rotating a
key is simpler than a one-time browser consent.

> **Migrating:** `/mcp/full` and `/api/dev/mcp` are removed. OAuth users: re-add the server at `https://support.example.com/mcp` and sign in again (old tokens were bound to the old URL). App API-key users: keep the same `Authorization: Bearer sk_app_…` key and change the URL to `https://support.example.com/mcp`.

### App API key (static)

Requires a Sortd app with a connected GitHub repository (bug tasks are only investigated against analyzed source). One command, using the same `SORTD_API_KEY` from Environment Variables above:

```bash
claude mcp add --transport http sortd https://support.example.com/mcp \
  -H "Authorization: Bearer $SORTD_API_KEY"
```

For any other MCP-capable editor, the equivalent config is:

```json
{
  "mcpServers": {
    "sortd": {
      "type": "http",
      "url": "https://support.example.com/mcp",
      "headers": { "Authorization": "Bearer sk_app_..." }
    }
  }
}
```

Reference the key from an environment variable rather than pasting it literally — this config file is usually committed. The key is app-scoped: the connection only ever sees bug tasks belonging to that one app, and there is no app id to pass.

Confirm it works by asking the assistant to run `sortd_ping` — it returns the app id and name the key resolved to.

### Troubleshooting the connection

Two different Sortd MCP URLs can be in play at once, each with its own credential and tool set, and connecting the right client to the wrong one produces confusingly quiet failures:

| Symptom | Cause |
|---|---|
| Client reports **connected, but zero tools** | Wrong URL: you are on the generated per-tenant MCP gateway (`https://support.example.com/api/mcp/<appId>`, the "Sortd calls your app" direction from MCP Integration above). It accepts its own token, connects cleanly, and advertises only tools approved in the portal — often none. The Sortd MCP at `https://support.example.com/mcp` always lists its `sortd_*` tools on any successful connection, so "connected but no tools" never means a permission problem — it means you are not talking to `/mcp`. |
| Client **fails to connect** with `401` (app key, `https://support.example.com/mcp`) | The key is wrong or empty. This endpoint takes the App API key (`sk_app_…`) — not the generated MCP token, and not the webhook secret. An empty key usually means the `$SORTD_API_KEY` reference didn't expand when the server was added (PowerShell reads `$env:SORTD_API_KEY`, not `$SORTD_API_KEY`) — re-add with the literal key to confirm, then move it back to an environment reference. |
| Client fails with `404`/`401` after an update, or you configured `/mcp/full` or `/api/dev/mcp` | Those URLs are removed. OAuth: re-add the server at `https://support.example.com/mcp` and sign in again (old tokens were bound to the old URL). App key: keep the same `sk_app_…` key and change the URL to `https://support.example.com/mcp`. |
| Plugin's first tool call re-opens the browser consent screen every time, or a call fails asking you to sign in again | The OAuth token expired, was revoked, or the org membership behind it changed. This is a normal re-auth prompt, not a bug — approve the consent screen again. If it keeps happening on every call rather than once, the client isn't persisting the token between calls; check the MCP client's own token storage, not Sortd. |

`sortd_ping` (app key) or `sortd_whoami` (OAuth) settles any remaining doubt: each names what the connection actually resolved to.

### Tools

| Tool | What it does |
|---|---|
| `sortd_ping` | Confirms the key resolves; returns which app this connection is scoped to. |
| `sortd_list_backlog_items` | Lists backlog items — bugs, tasks and chores — newest first; every row carries `type` (`bug`, `task` or `chore`) and `origin`. With no filter, returns every open (non-settled) item. Filter by `type`, status, severity, `affectedComponent`, a title search (`q`), `qaState` (`failed` answers "what failed QA?"), `updatedSince`, or a `createdAfter`/`createdBefore` range; `sort` (`createdAt`, `updatedAt`, `severity`, `title`) with `order` re-orders the list, `severity` accepts several values, and rows carry `dueDate` when set; `hasPhases: true` lists plans (items grouped into phases) instead. Compact rows only — no snippets or diffs. |
| `sortd_get_backlog_item` | Everything Sortd knows about one item. `item` and `brief` (what a developer works from), `carried` (what the item took from its ticket) and `thread` (the live customer conversation) lead; `developer` holds the full investigation for a bug: the customer's own report (`developer.customerReport`), root-cause hypothesis, suspect files with line ranges and snippets, evidence, the proposed patch (per-file blocks plus a unified diff), the commit it was reasoned against, your connected repos, the portal's `comments` thread, and — when the app uses QA — `qa` (state, QA person, round and fail counts) plus the last 5 QA rounds with QA's notes. For a plan it returns its phases, scope additions and the items inside it. |
| `sortd_claim_backlog_item` | Moves an item to **in-progress** so the portal shows it is being worked. Idempotent. Also reopens a settled (`closed`/`merged`/`released`) item — that requires `note`. Optional `claimedBy` records who is actually working it. If QA failed the item last time, the response carries `qaFailure` (QA's note) — start from it. An item currently In QA can only be claimed with a `note`, which withdraws it from QA. |
| `sortd_request_qa` | Only when the app has a QA workflow: hands a bug you fixed **and deployed** to QA (`deployedTo`: `staging` or `production`, plus `instructions` for how to test). The bug moves to **In QA** and the QA person is notified. Call it again to update the pending hand-off. A coding assistant on an app API key can never pass or fail QA — it never signs off its own work (only a signed-in QA person can, in the portal or the user-mode MCP). |
| `sortd_comment_backlog_item` | Posts a comment to the same thread a human sees on the item's portal page. Optional `attachments` take files you already uploaded with `POST /api/upload` (your app API key as Bearer); only urls from Sortd's own storage are accepted. |
| `sortd_investigate_backlog_item` | Requests an AI investigation of a bug — the **first** one (a bug is logged without an automatic investigation, so a bug with no analysis has not been looked at yet) or a fresh pass, e.g. when `commitSha` is stale or a proposed change is unverified. Spends LLM budget. |
| `sortd_resolve_backlog_item` | Records the outcome: `pr-created` (with the PR url/number/branch), `merged`, `released`, or `closed` (with a resolution — `fixed`, `wont-fix`, `cannot-reproduce`, `by-design`, `obsolete`, `duplicate`). If the app requires QA, `released` is refused (`QA_SIGNOFF_REQUIRED`) until QA has passed the fix on staging. |
| `sortd_list_tickets` | Lists support tickets, most-recently-updated first. With no filter, returns every ticket except `closed` (unlike bugs, `resolved` is not settled — it's the reporter-confirmation handshake) and excludes Actions — work the team assigned TO a customer, not outstanding support work — unless you pass `intent: "ACTION"` to ask for them explicitly. Filter by status, intent and priority (each one value or a list), category, `assignee` (`me`, `unassigned` or a member id; "me" needs a signed-in connection), `needsReply`, `reopened`, `due` (`overdue`/`soon`/`has`/`none`), `reporterEmail`, `tenantId`, `reason`, a search (`q`: title, display id, requester), or `updatedSince`; sort with `sortBy` and `sortDir`. |
| `sortd_get_ticket` | The full ticket record: status/priority/category, the resolution and fix-verification state, the customer-facing `thread`, the chat `context` that led up to it, the backlog items linked to it (`linkedItemIds`; the single `bugTaskId` is deprecated), and the portal's internal `comments` thread, plus `dueAt`, `assigneeId`, `reopenedAt`, `possibleDuplicateOf`, a cached `aiSummary` and, for team tickets and Actions, `participants` with completion state. The reporter's identity is omitted. |
| `sortd_find_people` | Looks a person up by partial name or email so you can fill in other tools: `endUsers` (the app's known end users, for `requesterEmail`) and — only when connected as a signed-in user — `staff` (team members assignable on this app, for `assignTo`). With an app API key only `endUsers` are returned. If several people match, ask which one is meant. |
| `sortd_create_ticket` | Files a new ticket (`issueType`: `bug`, `question` or `feature_request`) on behalf of a requester — write the `description` for support staff, with repro steps and environment. The ticket is always owned by a requester and visible to them, never internal-only: `requesterEmail` names them, and defaults to you when connected as a signed-in user. With an app API key `requesterEmail` is **required** and `assignTo` is refused; when signed in, `assignTo` takes a member id, `me`, or a team member's email/name (see `sortd_find_people`) and an unknown or ambiguous assignee is an error rather than silently ignored. Optional `subject`, `priority`, `category` (must be one of the app's categories), `notify` (`immediate`/`digest`/`silent`) and an idempotency `clientRequestId`. No attachments. |
| `sortd_create_tickets` | Bulk twin of `sortd_create_ticket`: files up to 50 tickets in one call (use it for a spreadsheet or doc of issues; for more, call in chunks of 50). Validation is all-or-nothing — if any row is invalid nothing is created and every problem is listed as `tickets[i].field`. Each ticket has a requester (the row's `requesterEmail`, else the batch default, else you when signed in; **required** with an app API key, which also refuses `assignTo`) and is never internal-only. `notify` defaults to `digest` (one summary notification, per-ticket webhooks still fire); `runTriage` (default `false`; set `true` to open an AI bug investigation for each bug row); an `externalRef` already filed through an earlier import is skipped as a duplicate. Returns per-row `created`/`skipped`/`failed` with ticket ids. Resending the identical payload (or `clientRequestId`) within 24 hours returns the original result (`replayed: true`) instead of filing duplicates; the batch can be undone from the portal Imports page. |
| `sortd_reply_ticket` | Posts a **reporter-facing** reply on the ticket's thread — the same one visible in the embed widget and the portal. Not for developer-only notes; use `sortd_comment_ticket` for those. Optional `attachments` (max 4) take files already stored in Sortd. |
| `sortd_update_ticket_status` | Moves a ticket through `open` → `in_progress` → `resolved` → `closed` (or `hold`). `closed` is only reachable from `resolved`. Optional `resolutionNotes` is shown to the reporter. |
| `sortd_comment_ticket` | Posts an internal, staff-only comment to the same thread a human sees on the ticket's portal page — never visible to the reporter. Optional `attachments` (max 4) take files already stored in Sortd. |
| `sortd_list_monitors` | Lists this app's service monitors (uptime/dependency checks), sorted by key, each with live status and — when one is open — a compact incident summary. |
| `sortd_get_monitor` | One monitor's full config and live status by key. `config.http.hasAuthHeader` says whether an auth secret is stored, without ever exposing it. |
| `sortd_upsert_monitor` | Creates a monitor, or updates one, keyed on `key` — idempotent, safe on every deploy. Three types: `http` (Sortd polls a URL you give it), `vendor_feed` (Sortd polls a status-page `summary.json`), `heartbeat` (the opposite direction — your backend pings Sortd, and a missed deadline is the failure). Every monitor created here is `source: 'api'`; editing a portal-owned one returns `409`. |
| `sortd_delete_monitor` | Deletes a monitor and its incident/stat history by key. Only a `source: 'api'` monitor can be deleted here — a portal-owned one returns `409`. |

### Typical Loop

Ask the assistant something like *"list the open Sortd bugs and fix them."* For each bug it will:

1. `sortd_list_backlog_items` — see what is open. Check `hasFixProposal`: `false` means Sortd could not draft a patch and `reasonNoFix` explains why, so the bug needs fixing by hand.
2. `sortd_get_backlog_item` — read the investigation under `developer`, starting with `developer.customerReport`. Everything else on the record is Sortd's account of the problem; `customerReport.triggerMessage` is the customer's own words, and it routinely carries the reproduction step a summary drops ("only on the second click", "only as an admin"). `conversationExcerpt` is the surrounding chat. Any `attachments[].url` on those messages — screenshots, screen recordings — is directly fetchable, and a screenshot is often the entire bug report, so open it. Check `comments` for anything a teammate already noted. Then compare `commitSha` against the current checkout; a patch drafted against an older commit may no longer apply cleanly, and `proposedChanges[].verified: false` means Sortd could not match its "before" text to the real file — call `sortd_investigate_backlog_item` for a fresh pass rather than trusting a stale one.
3. `sortd_claim_backlog_item` — mark it in-progress before editing, so a teammate reading the portal does not start the same work. Pass `claimedBy` with your name so the portal shows who, not just "coding assistant".
4. Fix it in the repo and open a pull request using the app's normal workflow. Use `sortd_comment_backlog_item` for anything a human should see that doesn't belong in a resolution note.
5. `sortd_resolve_backlog_item` — record the PR, or close the bug with a resolution.
6. **If the app requires QA**, deploy the fix and finish with `sortd_request_qa` (where you deployed, how to test) instead of marking it `released`. If QA fails it, the bug returns to you: read `qaFailure` from `sortd_claim_backlog_item` (or `developer.bug.qaRounds` from `sortd_get_backlog_item`) first, fix, redeploy and call `sortd_request_qa` again. After a pass on staging, deploy to production and call `sortd_resolve_backlog_item` with `outcome: "released"`.

A **task** (`type: "task"`, started from a feature request in the portal) follows the same loop: claim it, build it from its brief and `developer.customerReport` (there is no root-cause analysis or proposed patch, and `sortd_investigate_backlog_item` answers `TASK_KIND_MISMATCH`), then `sortd_request_qa` and `released`.

Closing with resolution `fixed` also resolves the customer tickets linked to that bug and, for a bug auto-spawned from runtime error reporting, closes its error group — the same behavior as clicking through the portal, including webhook dispatch.

A fix that didn't hold (a regression on a `merged`/`released`/`closed` bug) reopens the same way as a first claim: `sortd_claim_backlog_item` with a `note` explaining why. The note is required precisely because reopening clears the bug's prior resolution.

For the ticket behind a bug, once the fix is out: `sortd_reply_ticket` to tell the reporter directly, then `sortd_update_ticket_status` with `status: "resolved"` — this opens the confirm/still-broken handshake with them, exactly like a support agent resolving it in the portal.

### Guardrails

- **Cannot approve or reject Sortd's own AI-proposed patch.** `approved` and `rejected` are the human review verdict on a Sortd-drafted fix and are deliberately unreachable from this surface, so an assistant cannot self-approve its way into a PR. Do that in the portal.
- **Cannot reach another app's data.** Every read and write is filtered by the app the key resolved to.
- **Status transitions are enforced.** Recording an outcome a bug cannot legally move to returns `409` rather than silently writing — for example, marking a bug `merged` when it was never claimed or PR'd. Same for tickets: `sortd_update_ticket_status` to `closed` from anything but `resolved` returns `409`.
- **Cannot sign off its own work.** With QA on, nothing reaches `released` without a QA pass or an admin's recorded waiver, and no app-key tool passes or fails QA. Pulling a bug back out of QA needs a `note`.
- **Reopening a settled bug requires a reason.** `sortd_claim_backlog_item` on a `closed`/`merged`/`released` item without `note` returns `422` rather than silently clearing the prior resolution.
- **`sortd_reply_ticket` is reporter-facing.** It posts into the same thread the customer reads in the embed widget — write for them, not for a teammate. Use `sortd_comment_ticket` for anything staff-only.
- **Rate limit:** 120 requests/minute per app, then `429`.
- `SORTD_API_KEY` now also reads bug and ticket detail, including source snippets from your repo and the reporter's own conversation. That is the same trust level as the ticket and error endpoints, but worth knowing before sharing the key more widely than the backend that needs it.

### MCP Verification

Verify:

- `sortd_ping` returns the expected app id and name.
- `sortd_list_backlog_items` returns the same open items the portal shows for that app, and the `affectedComponent`/`q`/`updatedSince` filters narrow the results as expected.
- `sortd_get_backlog_item` on a bug with a drafted fix returns non-empty `developer.bug.codeFindings.suspectFiles` and a `developer.bug.fixProposal`.
- `sortd_get_backlog_item` on a bug raised from a chat returns `developer.customerReport.triggerMessage` with the customer's actual wording, and any screenshot they attached appears as a fetchable `attachments[].url`.
- `sortd_get_backlog_item` returns any comment left on the item's portal page under `developer.comments`, and a `sortd_comment_backlog_item` post appears there in the portal.
- `sortd_investigate_backlog_item` on an eligible bug returns it at status `queued` and a fresh investigation lands shortly after; on an ineligible one it returns `409`.
- Reclaiming a `closed`/`merged`/`released` bug without `note` returns `422`; with `note` it moves to `in-progress` and `reopenCount` increments.
- A wrong or missing key returns `401` and no tools are listed.
- After `sortd_resolve_backlog_item` with `outcome: "closed"`, the bug shows as closed in the portal and drops out of `sortd_list_backlog_items`.
- `sortd_list_tickets` returns the same tickets the portal shows for that app, and includes `resolved` ones by default.
- `sortd_get_ticket` returns the reporter's actual thread under `thread`, oldest first, matching what the embed widget shows.
- A `sortd_reply_ticket` post appears on the ticket in the portal and in the live embed widget, attributed to "Dev MCP (coding assistant)".
- `sortd_update_ticket_status` to `closed` from anything other than `resolved` returns `409`.
- A `sortd_comment_ticket` post appears under the ticket's internal comments in the portal, never in the reporter-facing thread.
- `sortd_upsert_monitor` creates a monitor that shows up in the portal with `source: "api"`; calling it again with the same `key` updates it in place rather than creating a duplicate.
- `sortd_upsert_monitor` against a `key` already configured from the portal returns `409` rather than overwriting it.
- `sortd_delete_monitor` removes an api-created monitor from the portal; against a portal-created one it returns `409`.

## Framework Hints

### Next.js App Router

- Put Sortd server calls (`/api/conversations`, `/api/conversations/:id/messages`) in route handlers, server actions, or server-only modules.
- Use `req.text()` in webhook route handlers to preserve the raw body for signature verification.
- Do not use `NEXT_PUBLIC_SORTD_API_KEY`.

### Express

- Capture raw body for the Sortd webhook route before JSON parsing.
- Use `express.raw({ type: 'application/json' })` on the webhook route or configure a verify hook.
- Keep `SORTD_API_KEY` in server process env.

### FastAPI

- Use `await request.body()` for raw bytes before parsing JSON.
- Verify HMAC with `hmac.compare_digest`.
- Store Sortd config in server environment or secret manager.

## Implementation Checklist

- [ ] Ask the developer to choose Embed or API integration (or both), then decide whether to add MCP as an enhancement.
- [ ] Add server-only Sortd environment variables.
- [ ] For Embed: configure `allowedOrigins` with every domain the widget/iframe will be served from — it will not load anywhere without at least one.
- [ ] For Embed via raw iframe or a custom component: set `allow="clipboard-write; display-capture; microphone; fullscreen"` plus the `allowfullscreen` attribute, and if the app sends its own `Permissions-Policy` header, add the Sortd origin to those four directives. The script loader does this for you.
- [ ] For Embed via a custom launcher: mount the iframe once and collapse it on minimize — never render it conditionally on an `open` flag, and never recompute its `src`.
- [ ] For API integration: implement a server function to open a conversation (`POST /api/conversations`) and append messages (`POST /api/conversations/:id/messages`).
- [ ] Track the active `conversationId` per end-user session so subsequent messages append to the same conversation.
- [ ] Use existing auth/session helpers to populate `endUser.externalId`, `email`, and `name`.
- [ ] Include `appTenantId` (where supported) when the client app is multi-tenant, and choose `role` deliberately — an `admin` session minted without `appTenantId` sees every ticket in the app, while an `admin` session minted with `appTenantId` is scoped to that tenant only; use `owner` for anyone who needs to see every tenant regardless. Pass `appTenantName` too, for readable tenant labels in the Sortd dashboard.
- [ ] Render agent replies via the SSE stream (browser) or by reading the conversation (server).
- [ ] Implement the `ticket.created` webhook handler with HMAC signature verification.
- [ ] Add tests for the new server function and webhook signature verification.
- [ ] Verify no Sortd secret is exposed to client code.
- [ ] Document how to configure the Sortd values for local, staging, and production.
- [ ] For Own-Agent integration: register an `ask_sortd_support` tool whose handler calls `POST /api/agent/ask`, taking the end-user identity from the app's session rather than from a tool argument.
- [ ] For Own-Agent integration: branch on `outcome`, never on the wording of `message`, and relay `message` to the user verbatim.
- [ ] For Own-Agent integration: render `needs_confirmation` as the app's own Yes/No card and resolve it through the `decide` endpoint — never by posting the user's "yes" back as a chat message.
- [ ] For Own-Agent integration: handle the `202` long-turn response by polling `outcomeUrl`.
- [ ] For Own-Agent integration: add the three system-prompt instructions, including the rule never to claim a ticket exists unless a `decide` call returned one.
- [ ] For MCP: if a global auth boundary (middleware/proxy/guard) sits in front of the routes, add an additive exemption for the Sortd service token first — one change that makes every route behind it reachable.
- [ ] For MCP: confirm the backend URL is publicly reachable, `SORTD_TOKEN` is set in the app's server environment, and the bearer token check is in place on the routes Sortd will call.
- [ ] For MCP: add a service-token auth path on any target route that currently authenticates only a user session/cookie.
- [ ] For MCP: confirm the relevant generated tools are approved in the portal (not just generated) — unapproved tools are never called.
- [ ] For MCP: verify the identity claim unless every exposed route returns only tenant-wide, non-user-specific data; verify it rejects missing or invalid claims with `401`.
- [ ] For MCP: enforce resource scoping on every route taking a resource id — compare `claim.ticket_id`/`claim.sub` against the requested resource and return `403` on mismatch.
- [ ] For MCP: return `400` (not `500`) for malformed tool arguments, e.g. an invalid resource id passed to a DB lookup.
- [ ] For raise-a-ticket (optional): wire up `POST /api/tickets` or `POST /api/embed/tickets` if the client app wants a direct "raise an issue" affordance, and handle the `429` rate-limit response.
- [ ] For runtime error reporting (optional): call `POST /api/errors` from a server-side error handler/process hook using the guide's reporter module verbatim, authenticated with a `SORTD_INGEST_KEY`, and enable **Track errors** in My Apps → [App] → Settings → Alerts — it 204s as a no-op until that toggle is on.
- [ ] For service monitoring (optional): declare each external dependency with `PUT /api/monitors/{key}` (or let a heartbeat auto-create itself on first ping), and never hardcode who gets alerted or whether it's public — that's portal-owned and survives every redeploy.
- [ ] For service monitoring, heartbeat type only: call `POST /api/monitors/heartbeat/{key}` after the job succeeds, chained with `&&` so a failing run never sends a ping.
- [ ] For service monitoring, heartbeat type only, when the scheduler cannot send headers: use the monitor's tokenised ping URL (generated in the portal, shown once) instead of the Bearer endpoint, and optionally call `/start` at the beginning and `/fail` on failure. Never paste the URL into chat or commit it.
- [ ] For fixing bug tasks from your editor (optional): connect the Sortd MCP server with `claude mcp add --transport http sortd <SORTD_BASE_URL>/mcp`, reference `SORTD_API_KEY` from the environment rather than pasting it into a committed config, and confirm with `sortd_ping`. Workstation tool — no app code changes.

## Completion Criteria

The integration is complete when:

- The selected integration mode works in the local development app.
- Secrets are server-only.
- For Embed: `allowedOrigins` is configured and the widget/iframe loads on the expected domain(s).
- For Embed: the mic button dictates and the record-screen button opens the OS share picker — both failing on click means a missing `allow` entry or `Permissions-Policy` directive, not a user permission problem.
- For Embed with a custom launcher: minimizing and reopening restores the conversation instantly, with no loading state and no iframe reload.
- The `endUser` identity passed to Sortd is stable and, where applicable, tenant-aware.
- A user message appended to a conversation produces an agent reply visible in the app UI.
- The `ticket.created` webhook is received, signature-verified, and routed to the team's support inbox.
- Error states are visible to developers and do not break the main app.
- For Own-Agent integration: the client's agent calls the tool, the user sees Sortd's `message` verbatim, and a `needs_confirmation` turn resolved with `confirm` produces a real ticket with a `displayId`.
- For MCP: the Sortd portal shows the MCP server as live, and the backend URL and token are confirmed reachable and accepted.
- For MCP with identity claims: a verified `sortdClaim` is available in the request context and contains the correct `sub` and `ticket_id`.
- For raise-a-ticket (if implemented): a ticket raised directly returns a `displayId` synchronously and produces no AI chat reply.
- For service monitoring (if implemented): a declared monitor shows up in the portal, a heartbeat monitor flips to `down` when its job stops pinging past `intervalSec + graceSec`, and `monitor.down`/`monitor.recovered` webhooks are received and signature-verified the same way as every other event in this guide.
- For the MCP connection (if connected): `sortd_ping` resolves the expected app, and `sortd_list_backlog_items` returns the same open items the portal shows.
