# Helix External API

> The programmatic surface partner / downstream systems use to read (and,
> where granted, write) Helix business data with a scoped API-client
> credential.
>
> **Audience:** engineers integrating an external system against Helix.
> **中文版:** [external-api.zh-CN.md](external-api.zh-CN.md).
>
> This is a **standalone feature**, separate from the *Identity Pull /
> Directory API* (which distributes employee + org data and has its own
> credential and admin surface — see
> [identity-pull-api.md](../integrations/identity-pull-api.md)). Different
> product, different credential, different admin menu.
>
> Part A — how to use it (auth, scopes, conventions). Part B — what's
> available (the resource catalog, grows over time). New scopes/endpoints
> update this file in the same PR (see the doc-update rule in `CLAUDE.md`).

**References:** the in-product reference is at **`/api-docs`**
(`https://helix.pingcap.net/api-docs`) — endpoints, params, response
schemas, and examples, rendered in-app. **Any logged-in Helix colleague
can open it** (it is documentation, no secrets), so share that link;
admins also reach it from the 对外 API console's "API docs" button. The
machine-readable spec is `GET /api/v1/open/openapi.json` (credential-gated;
import into Postman / client codegen). This Markdown file is the narrative
guide.

**Quickstart:** issued a key? Verify it first:

```bash
curl -H "Authorization: Bearer helix_api_…" https://helix.pingcap.net/api/v1/open/me
# → { "data": { "client": {...}, "scopes": [...] }, ... }
```

---

## Part A — How to use it

### A.1 Concepts

An integration is registered in Helix as an **API client** — a non-human
service principal independent of any employee account. It has a name +
stable **slug**, an accountable in-office **owner**, a set of **scopes**
(granted capabilities), and one or more **tokens** (`helix_api_…`).

API clients are managed by a Helix global admin in **Admin → 对外 API
(External API)**. There is no self-serve issuance: an admin grants
exactly the scopes you need and hands you the token (shown once).

### A.2 Base URL & versioning

```
https://helix.pingcap.net/api/v1/open/...
```

The version lives in the path (`/api/v1`). Additive fields are not
breaking; a breaking contract change ships under a new version prefix.

### A.3 Authentication

Every request carries a bearer API-client token:

```
Authorization: Bearer helix_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

- Tokens are issued, rotated, and revoked by a Helix admin. Rotation has
  an overlap window (≤3 live tokens per client): mint the new one, switch,
  revoke the old — zero downtime.
- Only `helix_api_…` tokens are accepted on the External API. A personal
  access token (`helix_pat_…`), an identity-consumer token (`helix_svc_…`),
  or a JWT is rejected with `401` — every credential plane is isolated.
- An invalid / expired token, or a disabled client, returns a uniform
  `401` (no oracle about which check failed).

### A.4 Authorization (scopes)

A request is allowed only if the client holds every scope the endpoint
requires; otherwise `403` with the missing scope(s) named (and audited).

| Scope | Grants | Notes |
|-------|--------|-------|
| *(none)* | `GET /api/v1/open/me` — verify your credential, echo granted scopes. | Any valid token. |
| `cloud.subscriptions:read` | List/read the cloud subscription inventory. | name / external id / owner / environment / status / budget. |
| `cloud.subscriptions:write` | PATCH cloud subscription metadata. | Add-on; requires `cloud.subscriptions:read`. Metadata only — never touches the cloud resource. |
| `software.catalog:read` | Read the software catalog (product list). | Powers the Feishu external-options endpoint (B.2). Name / category / status only — no seats / cost / owner. |
| `software.permissions:read` | Read the software permission catalog. | Powers the cascading Feishu permission dropdown (B.3). Permission key + name only. |
| `hr.absences:read` | Look up leave availability for named people (B.4). | Lookup-only (must name people by email). Date spans + coarse category + approval status — never the reason or specific leave type. |
| `cloud.billing:read` | Read cloud spend for the subscriptions your visibility grants (B.6). | Pre-discount (list) basis only — never the invoiced net. Per-account sums are not the provider invoice. |
| `hr.holidays:read` | Read the public-holiday calendar by country and date window (B.5). | Non-personal reference data — no person is named. Includes 调休 makeup workdays. Unmaintained country-years report `no_data`, never an empty holiday list. |
| `people.github:read` | Look up the person ↔ GitHub handle mapping (B.7) — forward by email, reverse by handle, or list a department. | The one scope here that can **list** people, so it is bounded by a per-client **people scope** (see B.7). Identity + handle + department only — no contact details. |

#### Cloud visibility — which accounts, not just which data

A scope says what *kind* of cloud data you may read. Which *accounts* you
may read is a separate, per-client setting an admin configures:

- **Unrestricted** (the default, and what every client had before
  2026-08-15) — the whole cloud inventory.
- **Restricted** — a union of whole providers plus individually named
  subscriptions. Anything outside it does not exist as far as your
  credential is concerned: it is absent from list results and returns
  `404` (not `403`) on a direct lookup or PATCH.

`GET /api/v1/open/me` reports your setting, so "I see fewer accounts than
I expect" is answerable without asking an admin:

```json
{ "cloud_visibility": { "restricted": true, "providers": ["aws"], "subscription_count": 3 } }
```

If the stored whitelist is ever unreadable, this surface says so
(`"misconfigured": true`) and the cloud endpoints resolve to **no
subscriptions at all** — the safe direction, but it needs an admin to
re-save the whitelist. Never read an empty result as "we have no cloud
accounts" without checking here first.

If an account you need is missing, ask your Helix admin to add it to your
client's cloud visibility — a scope change will not help.

### A.5 Conventions

**Response envelope** — every endpoint returns the standard Helix shape:

```json
{ "success": true, "data": ..., "meta": { "trace_id": "…" }, "errors": [] }
```

On error, `success: false` and `errors: [{ "code": "...", "message": "..." }]`.
`meta.trace_id` correlates a request to its audit/log rows.

**Pagination** — list endpoints use offset pagination: `page` (1-based) +
`page_size` (≤200); `meta` carries `page` / `page_size` / `total`. The
billing endpoints (B.6) allow `page_size` up to **1000**: a year of daily
rows across a few hundred subscriptions would otherwise cost 500+ calls
against a 300 req/min limit, for rows that are small and pre-aggregated.

**Errors** — `400/422` invalid input, `401` auth, `403` missing scope,
`404` not found, `409` conflict (e.g. writing a decommissioned resource),
`429` rate limited (with `Retry-After`).

**Rate limits** — per-client fixed-window cap (default 300 req/min,
admin-tunable per client). Over the cap → `429` + `Retry-After` (seconds).
Repeatedly hammering a dead token trips an anti-brute-force limiter.

**Idempotency** — `PATCH` is idempotent: re-sending the same body
converges to the same state and writes no duplicate audit row.

**Audit** — client actions are attributed to the client
(`actor_type=service`, client slug + IP in the row); no human actor is
implied for a machine call.

---

## Part B — What's available

### B.1 Cloud Subscriptions

The cloud subscription inventory — one row per billable cloud unit (AWS
account, Azure subscription, GCP project, Aliyun account, …).

```
GET   /api/v1/open/cloud/subscriptions          cloud.subscriptions:read
GET   /api/v1/open/cloud/subscriptions/{key}    cloud.subscriptions:read
PATCH /api/v1/open/cloud/subscriptions/{key}    cloud.subscriptions:write
```

`{key}` is the Helix subscription id (UUID) **or** the cloud-native
external id (e.g. an AWS account number). If an external id is ambiguous
across tenants, use the Helix id.

**Subscription object:**

| Field | Type | Description |
|-------|------|-------------|
| `id` | string (UUID) | Helix subscription id (stable primary key). |
| `external_id` | string | Cloud-native id (AWS account #, GCP project id, …). |
| `provider` | string | `aws` / `gcp` / `azure` / `aliyun` / … |
| `integration_name` | string | The cloud tenant/integration this belongs to. |
| `scope_type` | string | `aws_account` / `gcp_project` / `azure_subscription` / … |
| `name` | string \| null | Display name. |
| `environment` | string | `dev` / `staging` / `prod`. |
| `status` | string | Lifecycle: `active` / `suspended` = in use; `closed` = decommissioned (offline). |
| `owner` | object \| null | See the owner object below, or null if unowned. |
| `budget_usd_monthly` | number \| null | Monthly budget in USD. Read `budget_measure` before comparing it to your own spend figures. |
| `budget_measure` | string | Which cost basis `budget_usd_monthly` is denominated in: `"net"` (what the cloud actually invoices, the default and the value for every subscription unless stated otherwise) or `"list"` (pre-discount list cost, i.e. before company-level contract discounts). Added 2026-07-29; a `null` on older records reads as `"net"`. |
| `account_email` | string \| null | **Effective** account contact email — the owner override if set, otherwise the value the cloud provider reports (AWS Organizations account `Email`). This is the field to use. `null` when the provider reports no email (non-AWS providers today). Added 2026-08-04. |
| `cloud_account_email` | string \| null | The email exactly as the cloud provider last reported it; refreshed on every sync. Compare with `account_email_override` to tell a synced value apart from a human correction. |
| `account_email_override` | string \| null | The Helix-side override, or `null` when none is set (in which case `account_email` falls back to `cloud_account_email`). Writable via PATCH. |
| `updated_at` | string (ISO-8601) | Last modification time. |

**Owner object** (`owner`, when the subscription has an owner):

| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Owner display name. |
| `email` | string | Owner work email (operational contact, not PII-masked). |
| `department` | string \| null | Primary department **leaf** name only (e.g. `RD Infra`). `null` if the owner has no primary org membership. |
| `department_full_path` | string \| null | Full department chain, slash-joined, with the tenant root (`PingCAP`) stripped — e.g. `Engineering Group / TiDB / RD Infra`. `null` when the owner has no membership / only the tenant root. Added 2026-08-06. |
| `department_path_segments` | string[] | The same chain pre-split into an array — `["Engineering Group", "TiDB", "RD Infra"]` — so you needn't split the string yourself. `[]` when there is no path. Added 2026-08-06. |

> **Need the full department path?** It is right here on `owner` — you do
> **not** need the Directory API (`/api/v1/open/directory/...`) or a
> `directory.*` scope for this. Directory is a separate product with its
> own `helix_svc_…` credential; only reach for it if you need the org tree
> itself (sibling/parent nodes, node ids), not just an owner's department
> chain.

**Field-name mapping** (a few fields carry a Helix-internal name that maps
to the vendor-neutral concept an integrator expects):

| You may expect | Helix field | Meaning |
|----------------|-------------|---------|
| `cloud_account_tenant` | `integration_name` | The cloud tenant / integration instance the subscription belongs to. |
| `subscription_type` | `scope_type` | The kind of billable unit (`aws_account` / `gcp_project` / `azure_subscription` / …). |

**List — `GET /subscriptions`** — query params (all optional, combinable):

| Param | Description |
|-------|-------------|
| `provider` | `aws` / `gcp` / `azure` / `aliyun` / … |
| `environment` | `dev` / `staging` / `prod` |
| `status` | `active` / `suspended` / `closed` |
| `owner_email` | Only subscriptions owned by this work email (case-insensitive). |
| `q` | Keyword match on display name or external id. |
| `page` | 1-based, default 1. |
| `page_size` | 1–200, default 50. |

```bash
curl -s "https://helix.pingcap.net/api/v1/open/cloud/subscriptions?environment=prod" \
  -H "Authorization: Bearer helix_api_…"
```

```json
{
  "success": true,
  "data": [
    {
      "id": "b1c2…",
      "external_id": "123456789012",
      "provider": "aws",
      "integration_name": "AWS Prod",
      "scope_type": "aws_account",
      "name": "RD Compute",
      "environment": "prod",
      "status": "active",
      "owner": {
        "name": "Zhang San",
        "email": "zhangsan@pingcap.com",
        "department": "RD Infra",
        "department_full_path": "Engineering Group / TiDB / RD Infra",
        "department_path_segments": ["Engineering Group", "TiDB", "RD Infra"]
      },
      "budget_usd_monthly": 5000.0,
      "budget_measure": "net",
      "account_email": "aws-rd-compute@pingcap.com",
      "cloud_account_email": "aws-rd-compute@pingcap.com",
      "account_email_override": null,
      "updated_at": "2026-06-15T08:30:00+00:00"
    }
  ],
  "meta": { "trace_id": "…", "page": 1, "page_size": 50, "total": 1 },
  "errors": []
}
```

**Update — `PATCH /subscriptions/{key}`** (requires `cloud.subscriptions:write`)

Body — every field optional, at least one required:

| Field | Type | Rule |
|-------|------|------|
| `owner_email` | string | Must resolve to an **active (non-leaver)** Helix person (case-insensitive work email). |
| `environment` | string | `dev` / `staging` / `prod`. |
| `display_name` | string | Sets the display name; `""` clears it. |
| `notes` | string | Free text; `""` clears it. |
| `budget_usd_monthly` | number | ≥ 0. See the basis rule below — on a subscription already recorded as `list`, this field alone is refused. |
| `budget_measure` | string | `"net"` or `"list"` — the basis the `budget_usd_monthly` you are sending is denominated in. |
| `account_email_override` | string | Override the account contact email. A valid email sets it; `""` clears the override so `account_email` reverts to the cloud value. The provider-owned `cloud_account_email` itself is not writable. Invalid email → `422`. |

Omitted / `null` = leave unchanged. The API cannot null `owner` or
`budget` out — clearing those stays an admin action in the Helix console.

**Budget basis rule (2026-07-29).** A budget number is meaningless without
its basis, so writing `budget_usd_monthly` on a subscription whose recorded
`budget_measure` is `"list"` **without** also stating `budget_measure` is
refused with `422 CLOUD_BUDGET_MEASURE_REQUIRED`. Helix will not guess:
assuming "same basis as before" silently re-inflates a figure you meant as
an invoice amount (this is exactly how a real false-alert incident
happened), and assuming `"net"` breaks a legitimate list-basis raise. Only
the caller knows which number they typed. Subscriptions recorded as `"net"`
(the default, i.e. almost all of them) are unaffected — send
`budget_usd_monthly` alone as before.

Changing the basis re-derives every percentage-of-budget alert threshold on
the subscription (`threshold_usd ≡ pct × budget`) and re-stamps it, so the
basis and its thresholds can never disagree. `cloud.subscriptions:write`
**may** change the basis — an API consumer is an IT-issued service
principal, not a self-service scope owner; a human scope owner in the Helix
console may state the current basis but not move it (`403
CLOUD_BUDGET_MEASURE_FORBIDDEN` there). If you see that 403 from this API,
the token's scope was not `cloud.subscriptions:write`.

```bash
curl -s -X PATCH \
  "https://helix.pingcap.net/api/v1/open/cloud/subscriptions/123456789012" \
  -H "Authorization: Bearer helix_api_…" \
  -H "Content-Type: application/json" \
  -d '{"owner_email":"lisi@pingcap.com","environment":"prod","budget_usd_monthly":8000}'
```

Returns the updated subscription object. Errors: `409` if the
subscription is decommissioned (closed) or the target owner is a leaver;
`422` if `owner_email` matches no active person or the body is empty.

Writes are **metadata-only** — they update Helix's record (owner,
environment, name, notes, budget), never the cloud resource itself, so
they are unaffected by the cloud prod-write lock.

### B.2 Feishu external options — software catalog

A **Feishu-approval-specific** endpoint that feeds a form dropdown's
"associate external options" (关联外部选项) data source with the Helix
software catalog — e.g. to drive approval routing / filter conditions by
which software an approval is about.

```
POST /api/v1/open/feishu/software-options    software.catalog:read
```

This endpoint does **not** follow the standard Helix conventions above
(§A.5), because the caller is Feishu, not a generic integrator:

- **Auth is token-in-body, not a bearer header.** In the Feishu form
  designer you configure a *URL*, a *token*, and (optionally) a *key*.
  On each option fetch Feishu `POST`s the URL with the configured token
  in the JSON body. Make that token a `helix_api_…` token (issued the
  normal way, holding `software.catalog:read`) — Helix verifies it
  through the same credential plane (rotation / revocation / scope /
  audit) as the bearer surface.
- **The response is Feishu's envelope, not Helix's.** Success returns
  `{ "code": 0, "data": { "result": { … } } }` (V1, unencrypted). A
  bad/absent token → `401`, a token missing the scope → `403` (so a
  misconfiguration fails loudly during setup; Feishu treats any non-200
  as a failed fetch).
- **No response encryption.** Leave the Feishu *key* field blank — the
  payload is a public product-name list; the token + TLS already
  authenticate and protect it.

**Request** (sent by Feishu):

```json
{
  "token": "helix_api_…",
  "linkage_params": { "query": "zo", "page_token": "", "locale": "zh_cn" }
}
```

`linkage_params` is optional: `query` fuzzy-filters on software
name/version, `page_token` is the `nextPageToken` from a prior page,
`locale` is the requester's language. Only `status='active'` catalog
rows are offered.

**Response** (option `id` = software name; display text via `@i18n@`
keys resolved in `i18nResources` for every locale):

```json
{
  "code": 0,
  "msg": "success",
  "data": {
    "result": {
      "options": [
        { "id": "Zoom", "value": "@i18n@sw_<uuid>", "isDefault": false }
      ],
      "i18nResources": [
        { "locale": "zh_cn", "isDefault": true, "texts": { "@i18n@sw_<uuid>": "Zoom" } },
        { "locale": "en_us", "isDefault": false, "texts": { "@i18n@sw_<uuid>": "Zoom" } }
      ],
      "hasMore": false,
      "nextPageToken": ""
    }
  }
}
```

**Setup:** create an API client in *Admin → 对外 API* with only
`software.catalog:read`, mint a token, then in the Feishu form field's
external-options config paste the URL
`https://helix.pingcap.net/api/v1/open/feishu/software-options` and the
token; leave *key* empty.

### B.3 Feishu external options — software permissions (cascade)

The **cascading** partner to B.2: a permission dropdown *linked* to the
software dropdown. When the approver picks a software, Feishu passes the
selection into this endpoint's `linkage_params` and it returns that
software's grantable permissions.

```
POST /api/v1/open/feishu/permission-options    software.permissions:read
```

Same non-standard Feishu contract as B.2 (token-in-body, Feishu response
envelope, no encryption). The difference is the **dependent parameter**:

- In the Feishu form designer, on the permission field's external-options
  config, add a **dependent parameter** (依赖参数) that maps the key
  `software_name` to the value of the software field (B.2). Feishu then
  sends it in `linkage_params`.
- The endpoint resolves the software by that value (which is the software
  option's `id`, i.e. its **name**) and returns the software's active
  permissions. Until a software is picked, it returns an empty option
  list.
- If you name the dependent-parameter key something other than
  `software_name`, the endpoint also accepts `software` / `software_id`,
  and as a last resort the sole extra parameter — but `software_name` is
  the recommended, unambiguous key.

**Request** (sent by Feishu once a software is chosen):

```json
{
  "token": "helix_api_…",
  "linkage_params": { "software_name": "Zoom", "query": "", "page_token": "" }
}
```

**Response** — option `id` = the permission **key** (stable within the
software; combined with the software field it uniquely identifies the
permission for a downstream grant), display text = the permission's name:

```json
{
  "code": 0,
  "data": {
    "result": {
      "options": [
        { "id": "host",   "value": "@i18n@opt_0", "isDefault": false },
        { "id": "cohost", "value": "@i18n@opt_1", "isDefault": false }
      ],
      "i18nResources": [
        { "locale": "zh_cn", "isDefault": true,
          "texts": { "@i18n@opt_0": "Host", "@i18n@opt_1": "Co-host" } }
      ],
      "hasMore": false,
      "nextPageToken": ""
    }
  }
}
```

**Setup:** grant the same API client `software.permissions:read` (in
addition to `software.catalog:read` for B.2), set the permission field's
external-options URL to
`https://helix.pingcap.net/api/v1/open/feishu/permission-options` with
the same token, and add the `software_name` dependent parameter.

### B.4 Leave availability lookup

Answer, per named person, whether they are on leave in a date window — the
original driver being an on-call scheduler that must not assign a shift to
someone who is out. Leave data is mirrored daily from SuccessFactors EC
Time-Off.

```
POST /api/v1/open/hr/absences/lookup    hr.absences:read
```

This is a **lookup, not a listing**: you must name the people by work
email — there is no "list everyone's leave" call. Leave data is sensitive
and this surface has no per-client population filter, so naming the people
you already know is the access boundary.

**Request body:**

| Field | Type | Rule |
|-------|------|------|
| `emails` | string[] | 1–200 work emails (case-insensitive, de-duplicated). |
| `from` | string (date) | Window start, `YYYY-MM-DD`, inclusive. |
| `to` | string (date) | Window end, inclusive. Must be ≥ `from`; window ≤ 366 days. |

**Response** — one object per (de-duplicated) input email, in input order:

| Field | Type | Description |
|-------|------|-------------|
| `email` | string | The looked-up email (lower-cased). |
| `coverage` | string | `covered` — Helix has leave data for this person (they have SF leave accounts); `no_data` — the person exists but Helix has **no leave source** for them (e.g. an overseas employee not administered by SF EC Time-Off — treat as *unknown*, **not** "available"); `unknown_person` — the email resolves to no active Helix person. |
| `unavailable_spans` | array | Leave spans overlapping the window (see below). Empty when the person is not out (or `coverage != covered`). |

**Span object** (`unavailable_spans[]`):

| Field | Type | Description |
|-------|------|-------------|
| `start` / `end` | string (date) | The leave span (inclusive). |
| `days` | number \| null | Day count (`0.5` for a half-day); `null` if SF didn't report a quantity. |
| `category` | string | Coarse category — `leave`. The **specific** leave type (annual / sick / parental) is deliberately never exposed. |
| `status` | string | Approval status: `APPROVED` / `PENDING` / `PENDING_CANCELLATION`, or `unknown` when the source system didn't report one. Rejected / cancelled requests are **not** returned at all. |

> **Which spans count as "out"?** Approved, pending, and
> pending-cancellation (a cancellation requested but not yet applied — the
> leave still stands) are all returned, each with its `status` so you can
> filter. A span with an unreported status comes back as `unknown` rather
> than being dropped, so the feed never under-reports an absence. Decide
> for yourself whether to treat `PENDING` / `unknown` as blocking.

**`meta`** carries `as_of` (the feed's last sync time — data refreshes on a
daily cron, so a leave filed this morning may not appear until the next
sync; use this to decide whether to trust it for a same-day decision),
plus the echoed `from` / `to` / `window_days`.

**Errors:** `403` if the token lacks `hr.absences:read`; `422` for a bad
window (`to` < `from`, > 366 days) or an empty / oversized `emails` list.

```bash
curl -s -X POST \
  "https://helix.pingcap.net/api/v1/open/hr/absences/lookup" \
  -H "Authorization: Bearer helix_api_…" \
  -H "Content-Type: application/json" \
  -d '{"emails":["zhangsan@pingcap.com"],"from":"2026-08-10","to":"2026-08-17"}'
```

```json
{
  "success": true,
  "data": [
    {
      "email": "zhangsan@pingcap.com",
      "coverage": "covered",
      "unavailable_spans": [
        { "start": "2026-08-12", "end": "2026-08-14", "days": 3.0, "category": "leave", "status": "APPROVED" }
      ]
    }
  ],
  "meta": { "trace_id": "…", "as_of": "2026-08-06T02:10:00+00:00", "from": "2026-08-10", "to": "2026-08-17", "window_days": 8 },
  "errors": []
}
```

---

### B.5 Public holiday calendar

List the days the office is closed in a given country — the driver being the
same class of consumer as B.4: an on-call rota that must not page someone on a
national holiday, or a job scheduler that skips non-working days. HR maintains
one calendar per country/region per calendar year in the Helix HR console.

```
GET /api/v1/open/hr/holidays              hr.holidays:read
GET /api/v1/open/hr/holidays/countries    hr.holidays:read
```

Unlike B.4 this **is a listing**: the data names no person and is already
published to every employee, so there is no population boundary to enforce.

**`GET /holidays` query parameters:**

| Param | Type | Rule |
|-------|------|------|
| `country` | string | Comma-separated ISO-3166 alpha-2 or alpha-3 codes, or English/Chinese country names (`CN`, `JPN`, `japan`, `中国` all resolve). Max 60 per call. Omit for **every maintained country**. |
| `from` | string (date) | Window start, inclusive. Defaults to today (UTC). |
| `to` | string (date) | Window end, inclusive. Defaults to `from` + 365 days. Window ≤ 1098 days. |
| `year` | integer | Shorthand for a whole calendar year. **Conflicts with `from`/`to`** — passing both is a `422`. |

**Response** — one object per requested country:

| Field | Type | Description |
|-------|------|-------------|
| `country` | string | ISO-3166 alpha-2 code (or your raw input when it could not be resolved). |
| `country_name` | string \| null | English display name; `null` when the country is not maintained. |
| `coverage` | string | `covered` — HR maintains a calendar for at least one year the window touches; `no_data` — a real country, but **nobody has filled in a calendar** for the years you asked about; `unknown_country` — the value did not resolve to a country code at all. |
| `years_covered` | integer[] | The maintained years that intersect your window. |
| `holidays` | array | The day entries (see below). Always `[]` when `coverage != covered`. |

**Day entry** (`holidays[]`):

| Field | Type | Description |
|-------|------|-------------|
| `date` | string (date) | The calendar date. |
| `name` | string | English holiday name. |
| `name_local` | string \| null | Local-language name where HR maintains one (e.g. `劳动节` for China). |
| `day_type` | string | `public_holiday` — a day off; `makeup_workday` — a **调休 workday**: a weekend day officially designated a working day to offset a long holiday (China). |
| `observed_group` | string | Stable key shared by every day of one holiday **and** its makeup workdays, so you can fold a multi-day holiday back together. |

> **Read `coverage` before you read `holidays`.** A `no_data` country is *not*
> a country with no holidays — it is one Helix has not been told about. Treating
> the two the same is how a scheduler books work on a national holiday. Use
> `GET /holidays/countries` to discover what is maintained before you ask.

> **Multi-day holidays are one row per day.** China's Labor Day is five
> `public_holiday` entries plus one `makeup_workday`; group on `observed_group`
> to recover the span. Counting working days? Subtract `public_holiday` dates
> **and add back** `makeup_workday` dates, or a 调休 Saturday will look free.

**`meta`** carries `as_of` (newest `updated_at` across the returned scope — how
recently HR touched these calendars), the echoed `from` / `to`, and the
`day_types` vocabulary.

**Errors:** `403` if the token lacks `hr.holidays:read`; `422` for `year`
together with `from`/`to`, a reversed window, a window over 1098 days, or more
than 60 countries.

```bash
curl -s "https://helix.pingcap.net/api/v1/open/hr/holidays?country=CN&from=2026-05-01&to=2026-05-31" \
  -H "Authorization: Bearer helix_api_…"
```

```json
{
  "success": true,
  "data": [
    {
      "country": "CN",
      "country_name": "China",
      "coverage": "covered",
      "years_covered": [2026],
      "holidays": [
        { "date": "2026-05-01", "name": "Labor Day Holiday", "name_local": "劳动节", "day_type": "public_holiday", "observed_group": "cn-2026-labor-day-holiday" },
        { "date": "2026-05-02", "name": "Labor Day Holiday", "name_local": "劳动节", "day_type": "public_holiday", "observed_group": "cn-2026-labor-day-holiday" },
        { "date": "2026-05-09", "name": "Makeup Workday (Labor Day Holiday)", "name_local": "劳动节调休上班", "day_type": "makeup_workday", "observed_group": "cn-2026-labor-day-holiday" }
      ]
    }
  ],
  "meta": { "trace_id": "…", "as_of": "2026-08-12T02:15:00+00:00", "from": "2026-05-01", "to": "2026-05-31", "day_types": ["public_holiday", "makeup_workday"] },
  "errors": []
}
```

> **Not on this API:** the HR console also has `/hr/holidays/suggest` (pull a
> country-year's statutory dates from an offline dataset for HR to review) and
> `/hr/holidays/calendars/roll-forward` (open next year from this year's country
> roster). Both are authenticated HR endpoints, deliberately not exposed here —
> they are authoring tools, and this API is a read surface.

**Discovery — `GET /holidays/countries`** (optional `?year=`) returns one row per
maintained country-year: `country`, `country_name`, `year`, `holiday_days` (day
rows stored) and `stated_days` (HR's own published headline count of observed
days off, which excludes holidays falling on a weekend and nets out makeup
workdays — so it is normally lower than `holiday_days`).

---

### B.6 Cloud billing

Cloud spend for the subscriptions your credential can see.

```
GET /api/v1/open/cloud/billing/daily      cloud.billing:read
GET /api/v1/open/cloud/billing/monthly    cloud.billing:read
GET /api/v1/open/cloud/billing/breakdown  cloud.billing:read
GET /api/v1/open/cloud/billing/changes    cloud.billing:read
GET /api/v1/open/cloud/billing/tag-keys   cloud.billing:read
```

**Read this before you use the numbers:**

| Contract | What it means for you |
|---|---|
| **One cost basis: `list`** | Amounts are the **business pre-discount** figure — actual execution price with company-level contract discounts, commitments, credits, tax and adjustments stripped out. It is **not** the invoiced net amount, and there is no parameter to switch basis. |
| **⚠️ Amounts changed on 2026-08-15 (the basis did not)** | The row above has always been the contract, but the implementation did not fully honour it: refund/adjustment, commitment-fee, contract-discount and credit rows — all of which the contract says are stripped — were leaking into the amounts. As of 2026-08-15 the implementation matches the contract and counts normal-usage rows only. **Impact**: re-pulling the same window returns different amounts than you received before 2026-08-15, mostly **higher** (discount rows had been depressing the figure); accounts with batched refund processing days (typical on Alibaba Cloud) could previously return a negative window total, which can no longer happen. If you compute period-over-period deltas or have persisted historical values, re-pull anything dated before 2026-08-15 so your series is on one basis. |
| **Not an invoice** | Charges that land on no sub-account (support, tax, credits, adjustments) cannot appear in a per-subscription view, and the line-item and daily-aggregate calibers differ by ~1%. `meta.excludes_unallocatable` says so on every response. Do not reconcile this API against a provider invoice. |
| **Recent data moves** | Cloud billing exports keep back-filling for ~2–3 days and the current month is partial. Every row carries `is_provisional`; every response carries `data_through` and `provisional_from`. Re-pull anything at or after `provisional_from` on your next load. |
| **USD only** | CN-billed clouds are converted at the platform FX rate. A row with no resolvable rate is **omitted** and reported in `meta.unconverted_by_currency` — it is never emitted as `0`, because a zero would read as "this day cost nothing". |
| **Visibility applies** | Only subscriptions inside your client's cloud visibility (A.4) are aggregated. Naming one outside it in `?subscription=` matches nothing; it does not error. |

#### `GET /billing/daily`

One row per (day × subscription), optionally × service.

| Param | Default | Notes |
|-------|---------|-------|
| `start` / `end` | trailing 30 days, ending today | Inclusive dates. Max span **366 days** (**92** with the service breakdown). |
| `subscription` | — | Repeatable. Helix subscription id (UUID) or cloud-native external id. |
| `provider` | — | Repeatable (`aws` / `gcp` / `azure` / `aliyun` / …). |
| `environment` | — | Repeatable: `dev` / `staging` / `prod`. |
| `owner_email` | — | Subscriptions owned by this work email. |
| `group_by` | `subscription` | Or `subscription,service` — adds a `service` field and applies the 92-day cap. |
| `page` / `page_size` | 1 / 200 | `page_size` ≤ **1000** on billing endpoints (see A.5). |

```bash
curl -H "Authorization: Bearer $HELIX_API_TOKEN" \
  "https://helix.pingcap.net/api/v1/open/cloud/billing/daily?start=2026-07-01&end=2026-07-31&provider=aws&page_size=1000"
```

```json
{
  "success": true,
  "data": [
    {
      "date": "2026-07-01",
      "subscription_id": "b1c2d3e4-0000-0000-0000-000000000000",
      "external_id": "123456789012",
      "name": "RD Compute",
      "provider": "aws",
      "environment": "prod",
      "amount_usd": 812.4531,
      "currency": "USD",
      "is_provisional": false,
      "source": "cost_facts"
    }
  ],
  "meta": {
    "page": 1, "page_size": 1000, "total": 1,
    "measure": "list",
    "measure_note": "Business pre-discount (list) cost. Not the invoiced net amount — do not reconcile against a provider invoice.",
    "currency": "USD",
    "window": { "start": "2026-07-01", "end": "2026-07-31" },
    "data_through": "2026-07-31",
    "provisional_from": "2026-08-12",
    "excludes_unallocatable": true,
    "sources": ["cost_facts"]
  },
  "errors": []
}
```

`source` tells you which table answered: `cost_facts` (line-item truth) or
`billing_daily` (the per-day aggregate, used when the line-item ingest has
not caught up for that subscription-month). Both are the `list` basis; the
two calibers differ by about 1%.

#### `GET /billing/monthly`

One row per (month × subscription), optionally × service. Folded from the
same daily cells, so a month always equals the sum of its days on this API.

| Param | Default | Notes |
|-------|---------|-------|
| `start_month` / `end_month` | trailing 12 months | `YYYY-MM`, inclusive. Max **36 months** (**3** with the service breakdown). |
| others | — | Same as `/billing/daily`. |

`month_is_partial` is `true` when the month is still running, when it holds
any provisional day, or when your window only covers part of it — treat any
of the three as "not a closed number".

```bash
curl -H "Authorization: Bearer $HELIX_API_TOKEN" \
  "https://helix.pingcap.net/api/v1/open/cloud/billing/monthly?start_month=2026-01&end_month=2026-07"
```

#### `GET /billing/breakdown`

The window's total split along **one** dimension, largest first.

| Param | Default | Notes |
|-------|---------|-------|
| `start` / `end` | trailing 30 days | Max span **92 days** — a grouping aggregates every line item in the window in one pass. |
| `group_by` | `subscription` | **One or two** comma-separated dimensions (see below). |
| `top` | 20 (max 100) | Row cap, largest spend first. `meta.truncated_to_top` says when it applied. |
| `subscription` / `provider` / `environment` / `owner_email` | — | Same filters as `/billing/daily`. |

**Dimensions:**

| Dimension | Notes |
|---|---|
| `subscription` / `provider` / `environment` | Derived from the subscription itself. |
| `service` / `region` / `resource_type` | Off the billing line. |
| `department` / `owner` / `cost_center` | Helix's cost-allocation attribution. Includes an explicit `unallocated` row. |
| `resource` | Top-N by spend; see `without_resource_id_usd`. |
| **`tag:<key>`** | A cloud resource tag — AWS cost-allocation tags, GCP labels, Aliyun tags. Discover keys with `GET /billing/tag-keys`. |

**Crossing two dimensions** answers the questions a single axis cannot —
"what did this team spend, split by environment":

```bash
curl -H "Authorization: Bearer $HELIX_API_TOKEN" \
  ".../billing/breakdown?group_by=tag:usedby,environment&start=2026-07-01&end=2026-07-31"
```

Every row carries `keys` and `labels` objects (one entry per dimension)
alongside the flat `key` / `label`, so one- and two-dimension responses
parse the same way.

#### Tags: read this before trusting a tag chart

- **Tag keys are case-sensitive**, exactly as the cloud stores them.
  `tag:UsedBy` and `tag:usedby` are different keys — GCP labels are
  lower-case by rule, AWS tags are not. A key that matches nothing
  returns everything under `untagged` rather than an error, so check
  `meta.tag_coverage` before concluding a team spent nothing.
- **`untagged` is a first-class row.** Measured on production the day
  this shipped, ~21% of charges carry no tag at all.
- **`meta.tag_coverage`** gives `tagged_usd` / `untagged_usd` /
  `tagged_share` for the window.
- **`meta.providers_without_tags`** names clouds whose spend in this
  window carries no tags at all (their billing feed does not supply
  them). A tag chart structurally cannot see those clouds — this field
  is how you know not to read "provider X spent nothing" off it.

#### `GET /billing/tag-keys`

Which tag keys exist on the spend you can see, ranked by the money they
cover, so you can pick a dimension without guessing.

| Param | Default | Notes |
|-------|---------|-------|
| `start` / `end` | trailing 30 days | Max 92 days. |
| `limit` | 100 (max 500) | |
| `subscription` / `provider` / `environment` / `owner_email` | — | Same filters as `/billing/daily`. |

```json
{
  "data": [
    { "key": "usedby", "sampled_spend_usd": 1011082.0,
      "providers": ["alicloud_intl", "aws", "gcp"] }
  ],
  "meta": { "sampled_rows": 62097, "sampled_spend_note": "…" }
}
```

`sampled_spend_usd` **ranks** keys within a bounded sample — it is not
the key's total spend (the tag column is unindexed JSON, so the scan is
capped and recency-biased). For exact amounts use
`group_by=tag:<key>`. Tag *values* are not returned here; ask for them
with the grouping, which applies the same visibility rules.

```bash
curl -H "Authorization: Bearer $HELIX_API_TOKEN" \
  "https://helix.pingcap.net/api/v1/open/cloud/billing/breakdown?start=2026-07-01&end=2026-07-31&group_by=service"
```

```json
{
  "success": true,
  "data": [
    { "key": "Amazon EC2", "label": "Amazon EC2", "amount_usd": 18204.51, "share": 0.62 },
    { "key": "Amazon S3",  "label": "Amazon S3",  "amount_usd": 6140.02,  "share": 0.21 }
  ],
  "meta": {
    "group_by": "service",
    "measure": "list",
    "currency": "USD",
    "window": { "start": "2026-07-01", "end": "2026-07-31" },
    "total_usd": 29361.24,
    "excludes_unallocatable": true,
    "sources": ["cost_facts"]
  },
  "errors": []
}
```

**Three things to read in `meta` before you draw a pie chart:**

- **`total_usd` is the window's whole spend**, not the sum of the rows
  shown. With `group_by=resource&top=20` the rows are the top 20 only;
  `share` is each row's share of the grouped total.
- **`not_broken_down`** appears when some subscription's spend came from
  the daily aggregate (no line items for part of the window). That table
  has no service / owner / department / resource dimension, so the amount
  cannot be split — it is reported here, with the subscriptions
  responsible, instead of disappearing. Present only for the four
  dimensions that need line items; `subscription` / `provider` /
  `environment` absorb it normally.
- **`data_through`** is the latest day carrying data, same as on the
  daily and monthly endpoints.
- **`without_resource_id_usd`** (resource grouping only) is spend whose
  line items carry no resource id at all. GCP's standard billing export
  has no resource column, so for a GCP-heavy window a Top-20 can cover a
  small fraction of the total — this field is how you tell.

`share` is `null` when the grouped total is not positive (an all-credits
window has no meaningful denominator).

The `owner` grouping **names people** — display name and work email of the
person the cost-allocation ladder attributed the spend to. Ask for it only
if your integration needs per-person attribution.

`department` and `owner` groupings include an **`unallocated`** row for
line items Helix's cost-allocation ladder could not attribute to anyone.
It is never folded into another owner and never dropped.

#### `GET /billing/changes` — incremental pull

Which `(subscription, day)` cells were re-ingested since a timestamp, so
a nightly job re-pulls only those instead of the whole window.

| Param | Default | Notes |
|-------|---------|-------|
| `since` | *(required)* | ISO-8601. A `Z` suffix, a naive value (read as UTC), and an unescaped `+00:00` offset are all accepted. |
| `horizon_days` | 32 (max 92) | How far back to look. |
| `subscription` / `provider` / `environment` / `owner_email` | — | Same filters as `/billing/daily`. |
| `page` / `page_size` | 1 / 200 | `page_size` ≤ 1000. |

```json
{
  "data": [
    { "date": "2026-08-13", "subscription_id": "…", "external_id": "123456789012",
      "name": "RD Compute", "provider": "aws", "environment": "prod",
      "last_ingested_at": "2026-08-14T02:11:07+00:00" }
  ],
  "meta": {
    "since": "2026-08-13T00:00:00+00:00",
    "horizon": "2026-07-14",
    "changed_cells": 1,
    "signals": ["billing_daily.fetched_at", "cost_facts.updated_at"],
    "detects": "…",
    "does_not_detect": "…"
  }
}
```

**What this is, exactly.** An *ingest watermark*, not an amount diff: a
day that was re-fetched and came back with the same number is still
listed. A consumer overwriting by `(subscription, date)` does not care,
and under-reporting would lose data silently.

**What it will not tell you.** Changes to days older than `horizon`. If
an admin backfills a historical window, re-pull that window explicitly —
the endpoint refuses a `since` older than the horizon rather than
returning a partial answer that looks complete.

`/billing/daily` accepts the same cursor directly as
`?updated_since=<timestamp>`, filtering the series to the changed cells
(and reporting the same block under `meta.incremental`) It takes the same
`horizon_days` knob, with the same limits.

**Recommended nightly loop:**

1. Full pull once for the history you need.
2. Each run: `GET /billing/daily?start=…&end=…&updated_since=<last run's timestamp>`.
3. Store the response's `meta.incremental.since` … or simply your own
   request time, and overwrite rows by `(subscription_id, date)`.
4. Anything at or after `meta.provisional_from` will keep moving for a
   couple of days — that is normal, and the incremental feed will keep
   returning it until it settles.

**Suggested load pattern**: pull a wide window once, then re-pull only from
`provisional_from` on each subsequent run. Every read is audited against
your client, so a nightly full-year dump is visible as exactly that.

---

### B.7 Person ↔ GitHub handle lookup

```
POST /api/v1/open/people/github/lookup    people.github:read
```

Answers "who is this GitHub handle" and "what is this person's handle" on
demand. The driving use case is reconciling a GitHub organisation's member
list against the employee roster.

There are two ways to get this data and they are for different jobs. If your
system wants to **synchronise the roster** (keep its own copy, incrementally),
use the Directory pull API (`helix_svc_` credential, `github_id` field —
see `docs/integrations/identity-pull-api.md`). If you just want to **ask a
question now**, use this endpoint.

**Three selectors**, combinable in one call — at least one required:

| Param | Type | Rule |
|-------|------|------|
| `emails` | string[] | Forward lookup by work email. ≤200 per call, case-insensitive. |
| `github_ids` | string[] | **Reverse** lookup by handle — the direction a GitHub member list actually gives you. ≤200 per call, case-insensitive; the response echoes your spelling back so you can join it onto your own list. |
| `departments` | string[] | Everyone under an org path. ≤50 per call. Paths start *below* the tenant root and are slash-separated (`Engineering Group / TiDB`); sub-departments are included, and a sibling that merely shares a name prefix is not (`Engineering` never swallows `Engineering Support`). |
| `include_left` | boolean | Add departed staff. Default `false`. Set `true` for an offboarding reconciliation. |

**Response** — one block per selector you sent:

```json
{
  "data": {
    "emails": [
      { "email": "zhangsan@pingcap.com", "status": "found",
        "person": { "work_email": "zhangsan@pingcap.com",
                    "display_name": "Zhang San", "employee_no": "P12345",
                    "github_id": "zhangsan-pc", "employment_status": "active",
                    "department_full_path": "Engineering Group / TiDB" } },
      { "email": "nohandle@pingcap.com", "status": "no_github_id",
        "person": { "…": "…" } },
      { "email": "ghost@pingcap.com", "status": "unknown_person",
        "person": null }
    ],
    "github_ids": [
      { "github_id": "not-ours", "status": "unknown_handle", "person": null }
    ],
    "departments": {
      "requested": ["Engineering Group"], "unmatched": [], "people": [ … ]
    }
  },
  "meta": { "visibility": "unrestricted", "include_left": false,
            "person_count": 12 }
}
```

#### Read this before you remove anyone from your GitHub org

`github_id` is collected **at onboarding and never backfilled**, so it is a
sparse field. `null` means *"we have no handle on file"* — it does **not**
mean *"this person has no GitHub account"*.

That is why the per-email answer is a tri-state and not a boolean:

| `status` | Meaning | What to do |
|---|---|---|
| `found` | Person known, handle on file | Use it |
| `no_github_id` | Person known, **no handle recorded** | A coverage gap — ask them, don't act |
| `unknown_person` | No such person, **or outside your people scope** | Verify manually |

Never read `no_github_id` or a `null` handle as "not an employee" and remove
them on that basis. The actionable signal runs the other way: a handle that
is in your GitHub org but that Helix cannot name (`unknown_handle`) is the
one worth a human look.

#### People scope — which people, not just which data

Like cloud visibility (§A.4), a scope says what *kind* of people data you may
read; **which** people is a separate per-client setting an admin configures:

- **Unrestricted** (the default) — the whole roster.
- **Restricted** — a filter over departments / countries / legal entities /
  employment statuses / employment types / named individuals. Anyone outside it returns
  `unknown_person` / `unknown_handle`, i.e. **identical to "no such
  person"** — the endpoint never reveals that someone exists but is out of
  your reach.

`GET /api/v1/open/me` reports your own setting, so "I see fewer people than I
expect" is answerable without asking an admin:

```json
{ "person_visibility": { "restricted": true, "departments": ["50012345", "50012346"],
                         "department_count": 2, "include_subtree": true } }
```

The dimensions that bound you are named in full (departments, countries,
employment statuses, `employee_type_codes`, …); a large named-person list is
sampled with its total count alongside.

`employee_type_codes` carries raw SuccessFactors employment-type codes —
`1070` regular employee, `1071` EOR, `1072` intern, `1073` consultant,
`1074` outsourced, `1075` agency-dispatched. Codes rather than labels
because the upstream HRIS owns the vocabulary and has renamed entries
without renumbering them. A person the HRIS never typed matches no
employment-type filter, so a ceiling that names them excludes untyped
records (service accounts, pre-HRIS rows) by construction.

If the stored filter is ever unreadable, this surface says so
(`"misconfigured": true`) and the data endpoints resolve to **no people at
all** — the safe direction, but it needs an admin to re-save the filter.
Never read an empty result as "nobody has a handle" without checking here
first.

**Employment scope**: by default only currently-employed people are returned
(`active` and `leaver_pending` — someone serving notice still holds access, so
an access audit still wants them). `include_left=true` adds `left`. Pre-hires
are **never** returned under any flag.

**Other guarantees**: the response carries identity + handle + department
only — no phone numbers or personal emails, whatever your other scopes say.
A department selection matching more than 1000 people is a `422` rather than
a silently truncated list. Rows excluded from every Helix egress surface
(HRIS-purged records, SF no-push opt-outs, non-corporate-email junk) never
appear here either.

---

## Part C — Claude / MCP connector (OAuth)

This is a **different surface** from the `helix_api_` External API above.
The MCP endpoint exposes Helix as *tools* for AI agents (Claude, Cursor,
…) and authenticates with either a personal access token or OAuth.

**MCP endpoint**: `https://helix.pingcap.net/api/v1/mcp`

Two ways to connect:

1. **Config-file clients (Claude Code, etc.)** — mint a PAT under
   *个人令牌* and set an `Authorization: Bearer helix_pat_…` header:

   ```json
   {
     "mcpServers": {
       "helix": {
         "type": "http",
         "url": "https://helix.pingcap.net/api/v1/mcp",
         "headers": { "Authorization": "Bearer helix_pat_xxxxxxxx" }
       }
     }
   }
   ```

2. **GUI connectors (claude.ai "Add custom connector")** — paste only the
   URL `https://helix.pingcap.net/api/v1/mcp` and pick **Individual
   sign-in**. The connector auto-discovers Helix's OAuth 2.1 authorization
   server and walks you through SSO + a consent screen; no token to paste,
   no OAuth Client ID/Secret to fill (Helix registers the client
   dynamically). Each teammate connects as themselves.

**What the agent can do**: an OAuth-connected agent acts with your
**full Helix permissions** (same as a PAT) — it can read and, where your
role allows, act on your behalf. Write actions still go through the normal
approval chain and are blocked once you're offboarded. Revoke a connector
anytime under *个人令牌* (it appears as an OAuth grant).

**OAuth surface** (standards-based; implemented per the MCP
authorization spec — RFC 9728 / 8414 / 7591 / 7636 / 8707):

| Endpoint | Purpose |
|----------|---------|
| `GET /.well-known/oauth-protected-resource[/api/v1/mcp]` | Resource metadata (points at the AS). |
| `GET /.well-known/oauth-authorization-server` | AS metadata (endpoints, PKCE S256, grants). |
| `POST /api/v1/auth/oauth/register` | Dynamic client registration (public clients). |
| `GET /api/v1/auth/oauth/authorize` | Authorization endpoint → SSO + consent. |
| `POST /api/v1/auth/oauth/token` | `authorization_code` (PKCE) + `refresh_token` grants. |
| `POST /api/v1/auth/oauth/revoke` | Revoke a refresh token. |

Access tokens are short-lived (1h) and **audience-bound** to the MCP
endpoint; refresh tokens rotate single-use.

**Beyond tools** (2026-07-09): the MCP surface also advertises

- **Tool annotations** — every tool carries `readOnlyHint` /
  `destructiveHint` / `idempotentHint` (`openWorldHint=false`), derived
  from the server's own mutation ground truth. Clients can auto-run
  reads and reserve confirmation UX for the destructive subset.
- **Resources** — `helix://platform/overview` (how this surface
  behaves) and `helix://platform/tool-catalog` (live read/write catalog
  of all tools).
- **Prompts** — five curated playbooks (`it_overview`,
  `approvals_review`, `cost_review`, `offboarding_handover`,
  `onboarding_progress`) that appear as prompt templates in
  MCP-capable clients. Prompts add instructions, never authority.

---

## Changelog

- **2026-08-18** — Person ↔ GitHub handle lookup (B.7,
  `people.github:read`): forward by email, reverse by handle, or list a
  department. First External API surface that can LIST people, so it ships
  with a per-client **people scope** ceiling (`person_visibility`) — see
  B.7 and `GET /me`.
- **2026-07-09** — MCP surface completeness: ToolAnnotations on every
  tool + 2 platform Resources + 5 curated Prompts (Part C, "Beyond
  tools").
- **2026-07-06** — Feishu external-options endpoints: software catalog
  (B.2, `software.catalog:read`) + cascading software permissions
  (B.3, `software.permissions:read`). Token-in-body auth, Feishu response
  envelope; permission dropdown links to the software field.
- **2026-06-23** — MCP connector OAuth 2.1 + PKCE + dynamic client
  registration: Helix is now addable as a Claude/Cursor "custom
  connector" via Individual sign-in (Part C). PAT auth still works.
- **2026-06-15** — External API extracted into a standalone feature
  (`helix_api_` API clients + own admin menu). Cloud Subscriptions
  resource: read + write.

> For employee/org **directory** data, use the separate Identity Pull API
> ([identity-pull-api.md](../integrations/identity-pull-api.md)) — a
> different credential (`helix_svc_`) and admin surface.
