# Grantmaking.ai API — Guide for AI Agents

This document explains how to use the Grantmaking.ai User API. It is written
for AI agents and other programmatic clients. Everything here is current as of
2026-06.

Grantmaking.ai is a shared platform to discover, evaluate, and fund
high-impact AI safety work. The API gives a token holder read access to the
public directory of organizations and projects, write access to the entities
they are authorized to edit, and the ability to read/post comments on those
entities.

## TL;DR quickstart

```
BASE URL:  https://app.grantmaking.ai/api/v1
AUTH:      Authorization: Bearer xg_...        (required for writes, /me, and comment contents)
ANON:      Read-only GETs work WITHOUT a key (lower rate limit, private data hidden)
FIRST CALL (with key): GET /me                 (tells you what your key can do)
RATE LIMIT: 100 req/60s per token; ~30 req/60s per IP for keyless requests
FORMAT:    JSON in, JSON out. Success → { "data": ... }, error → { "error": "message" }
```

```bash
# No key needed for public reads:
curl "https://app.grantmaking.ai/api/v1/organizations?limit=5"

# With a key:
curl "https://app.grantmaking.ai/api/v1/me" -H "Authorization: Bearer $API_KEY"
```

## Anonymous (keyless) access

These endpoints work without any `Authorization` header:

- `GET /organizations` and `GET /organizations/{id}`
- `GET /projects` and `GET /projects/{id}`
- `GET /organizations/{id}/comments` and `GET /projects/{id}/comments` —
  returns only `{ "data": { "commentCount": N }, "meta": { "message": "…" } }`
  (the number of public comments), never the comment contents.

Anonymous rules:

- You are treated as a non-admin viewer: entities with private funding return
  `null` for their funding fields, exactly as for non-admin tokens.
- Rate limit is **per IP, roughly 30 requests per 60 seconds** (enforced at
  the edge; approximate). 429 responses carry a `Retry-After` header.
- Responses are CDN-cached
  (`Cache-Control: public, s-maxage=60, stale-while-revalidate=240`), so
  anonymous data can be up to ~5 minutes stale.
- Everything else — `GET /me`, all PATCH/POST/DELETE, and comment contents —
  requires a bearer token and returns 401 without one. Sending an
  `Authorization` header with an **invalid** token is a 401, not a fallback
  to anonymous. (The one keyless write is `POST /feedback` — see
  [Feedback & bug reports](#feedback--bug-reports) below.)

If you need comment contents, write access, or the higher rate limit: sign in
at https://app.grantmaking.ai and create an API key yourself on the
[Settings page](https://app.grantmaking.ai/settings) — it's self-serve.

## Critical gotchas (read these first)

1. **Always use the `app.grantmaking.ai` host.** That's where the API lives;
   the marketing site at `grantmaking.ai` is a separate host. If you send a
   request to a host that redirects to the canonical one (e.g. an `http://`
   URL or another alias), HTTP clients strip the `Authorization` header when
   following a cross-host redirect. For token-only endpoints that means a
   confusing 401; for anonymous-enabled GETs it's worse — the request silently
   succeeds as **anonymous**, so an admin token can get a response with private
   fields nulled and never see an error. Send requests directly to
   `https://app.grantmaking.ai`.
2. **Start with `GET /me`.** It tells you whether your token is admin-scoped
   and, if not, exactly which organizations and projects you can edit —
   including ready-made `apiPath` / `commentsApiPath` values you can call
   directly. Do not guess at permissions; discover them.
3. **PATCH bodies reject unknown fields.** Sending any field not in the
   allowlists below returns HTTP 400 `Unsupported field: ...`. Send only
   supported fields.
4. **List responses truncate long text.** `GET /organizations` and
   `GET /projects` clip long free-text fields to 2,000 characters (marked with
   a trailing `…` and a row-level `"truncated": true`). Fetch the single-entity
   endpoint for full text.
5. **Respect HTTP 429.** On rate limit you get a `Retry-After` header in
   seconds. Wait that long before retrying; do not retry in a tight loop.

## Authentication

Read-only GETs on organizations and projects work without a key (see
[Anonymous access](#anonymous-keyless-access) above). For everything else,
pass a profile API key (format `xg_…`) as a bearer token:

```http
Authorization: Bearer xg_...
Content-Type: application/json
```

Tokens are issued with a fixed scope (admin or non-admin) at creation time and
do not expire unless an expiry was set at issuance.

**Getting a key is self-serve.** Any signed-in user can create one at
https://app.grantmaking.ai/settings (API Keys panel): name it, optionally set
an expiry, and copy the `xg_…` value — it is shown **once** at creation and
only a hash is stored. The key's scope matches the account: admin accounts
get admin-scoped keys, everyone else gets a non-admin key whose editable set
is derived from their email domain (see `GET /me`). If you are an AI agent
without an account, ask the human you work for to create a key from their
Settings page.

All auth failures return **HTTP 401** with `{ "error": "..." }`:

| Situation                     | Message                                                         |
| ----------------------------- | --------------------------------------------------------------- |
| No `Authorization` header     | `Missing Authorization header`                                  |
| Header isn't `Bearer <token>` | `Invalid Authorization header format. Expected: Bearer <token>` |
| Token not found               | `Invalid API token`                                             |
| Token expired                 | `API token has expired`                                         |
| Token revoked                 | `API token has been revoked`                                    |

### Permission model

- **Admin-scoped tokens** can read and edit any organization or project.
- **Non-admin tokens** can edit an organization (and its projects) when the
  email domain on the token's profile matches the organization's website
  domain. Everything else is read-only.
- All valid tokens can read the public directory (`GET /organizations`,
  `GET /projects`) and edit their own profile (`PATCH /me/profile`).

## Discovery: `GET /me`

Always call this first. The response shape:

```jsonc
{
  "data": {
    "profile": { "id": "…", "email": "…", "name": "…" /* … */ },
    "token": { "id": "…", "isAdmin": false },
    "access": {
      "filtered": true, // false → admin: can edit everything
      "canEditAnyOrganization": false,
      "canEditAnyProject": false,
      "editableOrganizations": [
        // null when filtered: false
        {
          "id": "…",
          "name": "…",
          "descriptionShort": "…",
          "websiteUrl": "…",
          "apiPath": "/api/v1/organizations/<id>",
          "commentsApiPath": "/api/v1/organizations/<id>/comments",
        },
      ],
      "editableProjects": [
        /* same shape, plus orgId + organization */
      ],
    },
    "capabilities": {
      /* per-area flags, e.g. commentsRequireEditableEntity */
    },
  },
}
```

- `access.filtered: false` → the key is **admin-scoped**; `editableOrganizations`
  and `editableProjects` are `null` because admins can edit everything.
- `access.filtered: true` → only the listed entities are writable. Use the
  provided `apiPath` / `commentsApiPath` values directly.

## Endpoint reference

All paths are relative to `https://app.grantmaking.ai/api/v1`.

| Method & path                               | Purpose                                          | Access                                  |
| ------------------------------------------- | ------------------------------------------------ | --------------------------------------- |
| `GET /me`                                   | Who am I + what can I edit                       | Any valid token                         |
| `PATCH /me/profile`                         | Edit your own profile (and linked public person) | Any valid token                         |
| `GET /organizations`                        | Paginated list of organizations                  | Anonymous or any valid token            |
| `GET /organizations/{id}`                   | Read one organization (full text)                | Anonymous or any valid token            |
| `PATCH /organizations/{id}`                 | Edit an organization                             | Admin, or domain match                  |
| `GET /organizations/{id}/comments`          | List comments (anon: count only)                 | Anonymous (count) / admin, domain match |
| `POST /organizations/{id}/comments`         | Post a comment / reply                           | Admin, or domain match                  |
| `DELETE /organizations/{id}/comments/{cid}` | Delete a comment (soft delete)                   | Admin, or domain-match + own            |
| `GET /projects`                             | Paginated list of projects                       | Anonymous or any valid token            |
| `GET /projects/{id}`                        | Read one project (full text)                     | Anonymous or any valid token            |
| `PATCH /projects/{id}`                      | Edit a project                                   | Admin, or parent-org domain match       |
| `GET /projects/{id}/comments`               | List comments (anon: count only)                 | Anonymous (count) / admin, domain match |
| `POST /projects/{id}/comments`              | Post a comment / reply                           | Admin, or parent-org domain match       |
| `DELETE /projects/{id}/comments/{cid}`      | Delete a comment (soft delete)                   | Admin, or domain-match + own            |
| `POST /feedback`                            | Send feedback or a bug report                    | Public (no key needed)                  |

Comments support **create / list / delete** only (no edit), and exist only on
organizations and projects (not people or funds).

## Feedback & bug reports

Found a bug, or have feedback about the API or the platform? `POST /feedback`
is the one write that works **without a key** — built so an AI agent (or a
person) can fire off a quick note or bug ticket with zero setup. We read these.

Request body (JSON):

| Field     | Required | Notes                                                            |
| --------- | -------- | ---------------------------------------------------------------- |
| `message` | yes      | The feedback or bug report. Max 8,000 characters.                |
| `email`   | no       | A contact address, if you'd like a reply.                        |
| `source`  | no       | Where this came from, e.g. a URL or `"API"`. Max 500 characters. |

- **No `Authorization` header required.** Sending a valid token simply
  attributes the feedback to your account; it is never required.
- **Rate limited.** A few submissions per minute per IP. Separately, the number
  of notification emails we send is globally capped per hour — so during a
  flood your submission is **still stored** even when it doesn't trigger an
  email. `429` responses carry a `Retry-After` header (seconds).
- Unknown fields are rejected with `400 Unsupported field: ...`.

```bash
curl -X POST "https://app.grantmaking.ai/api/v1/feedback" \
  -H "Content-Type: application/json" \
  -d '{"message":"GET /projects 500s when limit=0","email":"you@example.com","source":"API"}'
```

Success → `201 { "data": { "id": "<uuid>" } }`.

## Response and error conventions

Success responses wrap the payload in `data`:

```jsonc
// Lists (truncated text fields):
{ "data": [ /* rows, each with "truncated": bool */ ], "meta": { "total": 142, "limit": 50, "offset": 0 } }

// Single entity (full text, no "truncated" flag):
{ "data": { /* object */ } }

// PATCH:
{ "data": { "id": "…", "updated": true } }

// POST .../comments → HTTP 201:
{ "data": { "id": "…", "content": "…", "visibility": "public", /* … */ } }

// DELETE .../comments/{cid}:
{ "data": { "id": "…", "deleted": true, "mode": "soft-deleted" } }
```

Errors are always `{ "error": "message" }` with an appropriate status:

| Status | Meaning                                                           |
| ------ | ----------------------------------------------------------------- |
| 400    | Malformed JSON, invalid UUID, unknown field, or failed validation |
| 401    | Auth failure (see table above)                                    |
| 403    | Valid token, but not authorized for this entity or field          |
| 404    | Entity/comment not found, hidden, or not visible to you           |
| 409    | Profile patch needs a linked public person that doesn't exist     |
| 429    | Rate limited — honor the `Retry-After` header (seconds)           |
| 500    | Server error — safe to retry once after a short delay             |

Token-authenticated responses carry `Cache-Control: private, no-store`; do
not cache them. Anonymous 200s carry
`Cache-Control: public, s-maxage=60, stale-while-revalidate=240` (plus
`Vary: Authorization`) and may be served from the CDN, up to ~5 minutes stale.

## Rate limiting

- **With a token:** 100 requests per rolling 60 seconds, per token.
- **Anonymous (no token):** roughly 30 requests per 60 seconds, per IP,
  enforced at the edge.

Exceeding either returns HTTP 429 `{ "error": "Too many requests" }` with a
`Retry-After` header in whole seconds.

Agent guidance:

- Use pagination (`limit=100`) instead of many small requests.
- On 429, sleep for `Retry-After` seconds, then resume.
- For bulk reads of the full directory, ~6 requests fetch all organizations
  and ~13 fetch all projects at `limit=100` — comfortably inside the limit,
  even anonymously.
- The anonymous budget is shared across all your keyless requests.
  CDN-cache hits (repeat list/entity GETs) may be served at the edge without
  consuming it, but non-cached endpoints like `.../comments` always count —
  a mixed workload can hit 429 sooner than the headline number suggests.

## Reading the directory

Both list endpoints share a common set of search, filter, and sort params
(below). `limit`/`offset` are clamped; every other listed param returns **400**
on an invalid value (unknown enum, malformed date/UUID, unsupported sort
column). Unknown query keys (e.g. `utm_source`) are ignored. `meta.total`
reflects the **filtered** count. Filters compose with `AND`.

**Shared params** (organizations + projects):

| Param                   | Type                                 | Behavior                                                                                                                                                                             |
| ----------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `limit`                 | 1–100 (default 50)                   | Clamped, not rejected (`limit=0` → 1, `limit=500` → 100).                                                                                                                            |
| `offset`                | ≥ 0 (default 0)                      | Clamped.                                                                                                                                                                             |
| `q`                     | string (≤ 100 chars)                 | Case-insensitive **substring match on `name` only** (`%`/`_`/`\` are matched literally).                                                                                             |
| `location`              | string (≤ 100 chars)                 | Case-insensitive substring match on the `location` field.                                                                                                                            |
| `descriptionContains`   | string (≤ 100 chars)                 | Case-insensitive substring match across the description fields (OR). Orgs: `descriptionShort`/`descriptionMedium`/`descriptionFull`; projects add `theoryOfChange`. Blank → ignored. |
| `tags`                  | slug (multi, ≤ 25)                   | Filter to entities carrying the given tag **slugs** (see `GET /tags`). Combine with `tagMatch`. Present-but-empty (`tags=`) → 400. Unknown slugs are not an error (see `tagMatch`).  |
| `tagMatch`              | `any` \| `all`                       | Default `any`. `any` = entity has at least one of the slugs; `all` = entity has every slug. Other value → 400.                                                                       |
| `isActivelyFundraising` | `true`/`false`/`1`/`0`               | Exact match.                                                                                                                                                                         |
| `updatedAfter`          | ISO datetime or `YYYY-MM-DD`         | `updatedAt >= value`. The main incremental-sync filter.                                                                                                                              |
| `updatedBefore`         | ISO datetime or `YYYY-MM-DD`         | `updatedAt < value`; a date-only value means the next UTC midnight (end-exclusive).                                                                                                  |
| `sort`                  | `name` \| `createdAt` \| `updatedAt` | Default `name`. Any other value → 400. `id` is always appended as a stable tiebreaker.                                                                                               |
| `order`                 | `asc` \| `desc`                      | Default `asc`.                                                                                                                                                                       |

Multi-value params (`orgType`, `status`, `orgIds`, `tags`) accept either
repeated keys (`?status=active&status=paused`) or comma-separated values
(`?status=active,paused`).

**Unknown tag slugs** (not in the vocabulary returned by `GET /tags`) are never
a 400 — they simply narrow results: ignored under `tagMatch=any`, and they make
`tagMatch=all` yield an empty set (an entity cannot have a slug that does not
exist). Example: `?tags=mech-interp,governance&tagMatch=all` returns only
entities tagged with **both** `mech-interp` and `governance`.

> **No funding filters or sort.** There is intentionally no way to filter or
> sort on `annualBudget`, `fundingGoal`, `fundingRaisedToDate`, or any other
> funding amount/JSON field. Result inclusion/order on a redacted private amount
> would leak it. Private-funding rows still appear in results when they match a
> public filter — their funding fields just come back `null`.

### `GET /organizations`

Returns non-hidden organizations. Supports all shared params above, plus:

| Param     | Type         | Behavior                                                                                      |
| --------- | ------------ | --------------------------------------------------------------------------------------------- |
| `orgType` | enum (multi) | `IN (...)` over `org_type` (`nonprofit`, `research_org`, `academic`, …). Unknown value → 400. |

Responses also include read-only fields not listed in the PATCH tables —
e.g. `logoUrl`, `createdAt`, `updatedAt` (and `squareLogoUrl` on projects).
Treat any field you don't recognize as read-only.

Long text fields (`descriptionShort`, `descriptionMedium`, `descriptionFull`,
`theoryOfChange`) are truncated to 2,000 characters in list responses; rows
with clipped fields carry `"truncated": true`. `GET /organizations/{id}`
returns full text (404 if not found or hidden).

**Funding privacy:** when an org has `isFundingPrivate: true`, the fields
`annualBudget`, `monthlyBurnRate`, `currentRunwayMonths`, `fundingGoal`, and
`fundingRaisedToDate` come back as `null` to non-admin tokens. A `null` there
does not mean the data doesn't exist — do not "fix" it by writing values.

### `GET /projects`

Same pagination, truncation, and shared filter/sort params as organizations,
plus:

| Param    | Type         | Behavior                                                                                            |
| -------- | ------------ | --------------------------------------------------------------------------------------------------- |
| `orgId`  | UUID         | Filter to one organization's projects. Malformed → 400 `Invalid orgId format`.                      |
| `orgIds` | UUID (multi) | `IN (...)` over `org_id`; up to 100 ids. **Mutually exclusive with `orgId`** — sending both → 400.  |
| `status` | enum (multi) | `IN (...)` over `status` (`active`, `completed`, `paused`, `cancelled`, `upcoming`). Unknown → 400. |

Project rows carry `orgId` only — there is **no embedded `organization`
object** in `GET /projects` or `GET /projects/{id}`. The embedded
organization (with `name`, `apiPath`, etc.) appears only inside `/me`'s
`editableProjects`. To resolve an org, fetch `GET /organizations/{orgId}`.

**Funding privacy:** when a project has `isFundingPrivate: true`, non-admin
tokens see `fundingGoals`, `fundingAmountRequested`, `fundingRaisedToDate`,
`annualBudget`, `monthlyBurnRate`, and `currentRunwayMonths` as `null`.

### `GET /tags`

Returns the full controlled tag vocabulary with usage counts per entity type, so
you know which `tags` slugs to pass to `GET /organizations?tags=…` or
`GET /projects?tags=…`. Anonymous-readable and CDN-cacheable. Ordered by
`label` ascending, then `slug`.

| Param        | Type                                              | Behavior                                                                               |
| ------------ | ------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `entityType` | `organization` \| `project` \| `person` \| `fund` | Optional. Returns only tags used by that entity type (count > 0). Invalid value → 400. |

```bash
curl "https://app.grantmaking.ai/api/v1/tags"
```

```json
{
  "data": [
    {
      "slug": "mech-interp",
      "label": "Mechanistic Interpretability",
      "description": "…",
      "counts": { "organizations": 12, "projects": 30, "persons": 5, "funds": 1, "total": 48 }
    }
  ],
  "meta": { "total": 1 }
}
```

## Editing entities

```bash
curl -X PATCH "https://app.grantmaking.ai/api/v1/organizations/$ORG_ID" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"descriptionShort":"Updated short description"}'
```

Rules that apply to all PATCH endpoints:

- Only allowlisted fields may be sent; any other key → 400
  (`Unsupported field: …`).
- A body with zero recognized fields → 400
  (`At least one supported field is required`).
- `name`, when present, must be a non-empty string.
- Setting a nullable field to `null` clears it. Omitting a field leaves it
  unchanged. **Send only the fields you intend to change.**

### Organization fields (`PATCH /organizations/{id}`)

| Field                                                                        | Type / rules                                                         |
| ---------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `name`                                                                       | string, required if present                                          |
| `descriptionShort`, `descriptionMedium`, `descriptionFull`, `theoryOfChange` | string or null                                                       |
| `websiteUrl`, `linkedinUrl`                                                  | valid http/https URL or null                                         |
| `location`, `fundingStage`, `fiscalSponsor`, `trackRecord`                   | string or null                                                       |
| `orgType`                                                                    | known org type string, or null to clear                              |
| `foundedDate`                                                                | `YYYY-MM-DD` or null                                                 |
| `teamSize`, `currentRunwayMonths`                                            | integer ≥ 0 or null                                                  |
| `annualBudget`, `monthlyBurnRate`, `fundingGoal`, `fundingRaisedToDate`      | decimal ≥ 0 or null                                                  |
| `isActivelyFundraising`, `isFundingPrivate`                                  | boolean                                                              |
| `donationLinks`                                                              | array of `{ platform, url }` (url must be valid http/https), or null |

> **Non-admin keys cannot set `websiteUrl`** → HTTP 403. The website domain
> determines who can edit the org, so changing it would change permissions.

### Project fields (`PATCH /projects/{id}`)

| Field                                                                                            | Type / rules                                                       |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
| `name`                                                                                           | string, required if present                                        |
| `descriptionShort`, `descriptionMedium`, `descriptionFull`, `theoryOfChange`, `expectedDuration` | string or null                                                     |
| `websiteUrl`, `linkedinUrl`                                                                      | valid http/https URL or null                                       |
| `location`, `fundingStage`, `fiscalSponsor`, `trackRecord`                                       | string or null                                                     |
| `status`                                                                                         | one of `active`, `completed`, `paused`, `cancelled`, `upcoming`    |
| `startDate`, `endDate`                                                                           | `YYYY-MM-DD` or null (`endDate` cannot precede `startDate`)        |
| `teamSize`, `currentRunwayMonths`                                                                | integer ≥ 0 or null                                                |
| `annualBudget`, `monthlyBurnRate`, `fundingRaisedToDate`                                         | decimal ≥ 0 or null                                                |
| `isActivelyFundraising`, `isFundingPrivate`                                                      | boolean                                                            |
| `fundingGoals`                                                                                   | object `{ minimum?, goal?, stretch? }`, each a number ≥ 0, or null |
| `orgId`                                                                                          | UUID or null                                                       |
| `donationLinks`                                                                                  | array of `{ platform, url }`, or null                              |

> **Non-admin keys cannot set `orgId`** → HTTP 403. Edit rights on a project
> derive from its parent organization, so reassigning it is admin-only.

### Profile fields (`PATCH /me/profile`)

Writes to two records: your account profile and, where applicable, your
linked public person.

| Field                                             | Written to       | Notes                                                           |
| ------------------------------------------------- | ---------------- | --------------------------------------------------------------- |
| `name`                                            | profile + person | required if present                                             |
| `bio`                                             | profile + person |                                                                 |
| `displayName`                                     | profile          | auto-filled from `name` if you set `name` without `displayName` |
| `titleAndOrg`                                     | profile          | max 120 characters                                              |
| `personalWebsiteUrl`, `linkedinUrl`, `twitterUrl` | person           | valid http/https URL or null                                    |
| `lesswrongHandle`, `eaForumHandle`, `location`    | person           | string or null                                                  |

> Person-only fields (everything except `name` / `bio` / `displayName` /
> `titleAndOrg`) require a linked public profile; without one they return
> HTTP 409 (`No linked public profile found`).

## Comments

Anonymous callers may `GET .../comments` but receive only the number of
public comments:

```jsonc
{
  "data": { "commentCount": 4 },
  "meta": { "message": "Comment contents are not available without an API key. …" },
}
```

Reading comment **contents** and posting comments requires being an
authorized **editor** of the entity (admin token, or matching email domain).
Other valid tokens get 403.

What you see in `GET .../comments` depends on the token:

- **Admin / reviewer / funder tokens** see all public + all restricted comments.
- **Other authorized editors** see all public comments plus only the
  restricted comments they themselves authored.

### Posting

```bash
curl -X POST "https://app.grantmaking.ai/api/v1/organizations/$ORG_ID/comments" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"content":"Comment text","parentCommentId":null,"visibility":"public"}'
```

- `content` — required, max 10,000 characters.
- `parentCommentId` — a parent comment's UUID to reply; `null`/omit for a
  top-level comment.
- `visibility` — `"public"` (default) or `"restricted"` (private note visible
  per the rules above).
- **Replies inherit restriction:** replying to a `restricted` comment forces
  the reply to `restricted` regardless of what you send. The response echoes
  the stored `visibility` — check it.

### Deleting

`DELETE .../comments/{commentId}`. Non-admins may delete only their own
comments. Any comment you can't act on (missing, already deleted, different
entity, not yours) returns 404 `Comment not found` — the endpoint never
reveals comments you can't act on.

Deletes are always **soft**: the row remains as a tombstone
(`content: "[deleted]"`, author masked, `deletedAt` set) so threads don't
break.

## Recommended agent workflow

0. No API key? You can still do step 2 (read the directory) anonymously at
   the lower rate limit. Skip `/me`, writes, and comment contents.
1. `GET /me` — learn your scope and editable entities.
2. To survey the ecosystem: page through `GET /organizations` and
   `GET /projects` with `limit=100`, then fetch single entities for any rows
   marked `truncated: true` that you need in full.
3. To update data: confirm the entity is in your editable set (or that you're
   admin), PATCH only the changed fields, and verify the
   `{ "updated": true }` response.
4. To leave notes for humans: POST a comment; use `"restricted"` for
   internal/reviewer-only notes and `"public"` otherwise.
5. Back off on any 429 using `Retry-After`; treat 5xx as retryable once.

## Windows / PowerShell note

On Windows PowerShell, `curl` aliases `Invoke-WebRequest`, which doesn't
accept `-X`/`-H`/`-d` and mangles UTF-8 bodies. Use the real binary
`curl.exe` and pass JSON bodies from a UTF-8 file:

```powershell
curl.exe -X POST "https://app.grantmaking.ai/api/v1/organizations/$ORG_ID/comments" `
  -H "Authorization: Bearer $API_KEY" `
  -H "Content-Type: application/json; charset=utf-8" `
  --data-binary "@body.json"
```
