# SDKs — generated from the contract, never written by hand

**Date:** 2026-07-29 · Backlog item **S3** · Script: [`scripts/generate_sdks.sh`](../../../scripts/generate_sdks.sh)

Two SDKs — TypeScript and Python — are **functions of** [`openapi.yaml`](./openapi.yaml). Nobody
writes one. Nothing generated is committed. One command produces both, and the command fails if the
contract is invalid or either SDK does not build.

---

## Run it

```bash
cd your checkout/brain-platform
scripts/generate_sdks.sh
```

Real output, re-run 2026-07-29 from a clean generated tree:

```
── toolchain
── validating docs/initiatives/memory-api/openapi.yaml
docs/initiatives/memory-api/openapi.yaml: OK
   every $ref resolves
   contract version 0.1.0
── python
Generating dist/sdk/python/brain-memory-api-client
   python sdk imports: the two calls that are the whole product are present
── typescript
@hey-api/openapi-ts v0.99.0
[Job 1] ✓ dist/sdk/typescript/src · 4 files · 92ms
   typescript sdk typechecks under --strict
── postcondition
   all 15 operations present in both SDKs

generated v0.1.0 from docs/initiatives/memory-api/openapi.yaml
  dist/sdk/python/brain-memory-api-client   88 files
  dist/sdk/typescript                       16 files
```

The dated 2026-07-27 timing snapshot was `10.5 s` cold with warm pip/pnpm caches and `4.1 s` warm;
the 2026-07-29 run above re-verifies contract shape, imports, typechecking, operation presence, and
file counts rather than claiming a new benchmark. The TypeScript side needs `pnpm` **or** `npx` on
`PATH` — this machine has only `pnpm`, so the `npx` branch is written but unproven and CI is where
it gets its first real run. Python needs nothing but an interpreter ≥3.11: `/usr/bin/python3` on
macOS is 3.9 and silently resolves the generator to a years-old release instead of failing, so the
script picks the interpreter rather than inheriting it.

---

## What you get

```
dist/sdk/                     gitignored, disposable, NEVER hand-edited
  .toolchain/py/              the pinned generators, in their own venv
  python/brain-memory-api-client/   88 Python files · 8,277 lines · installable package
  typescript/                 16 TypeScript files · 3,291 lines · package.json + tsconfig + src/
```

**1,202 lines of contract → 11,568 lines of client, in two languages.** That ratio is
the whole argument: it is the volume of code nobody has to review, keep in sync, or be wrong in.

All **15 operations** are present in both, named by `operationId` — `createBrain`, `getBrain`,
`forgetBrain`, `getProfile`, `getCensus`, `listTraces`, `getTrace`, `rememberRecord`, `getRecord`,
`forgetRecord`, `listMemories`, `getMemory`, `acceptObject`, `getObject`, and `createContext`.

The TypeScript package has **zero runtime dependencies** — the fetch client is emitted inline, so
there is no third-party package between a caller and the contract.

The Python package **builds, installs and imports** as part of the run. A generator that emits a
tree of files that does not import is a generator that failed quietly; this one is checked.

### The knob budget lands in the type system

This is the property that makes generation more than convenience. `ContextRequest` in the generated
Python, unedited:

```python
query: str
purpose: str | Unset = UNSET
as_of: datetime.datetime | Unset = UNSET
budget: ContextRequestBudget | Unset = UNSET
include: list[ContextRequestIncludeItem] | Unset = UNSET
schema: ContextRequestSchema | Unset = UNSET
filter_: ContextRequestFilter | Unset = UNSET
mode: ContextRequestMode | Unset = ContextRequestMode.FAST
```

Seven read knobs, and `mode` defaults to `fast` because the contract says so. An eighth knob appears
here, and in `types.gen.ts`, in the same commit that adds it — visible to every reviewer, in two
languages, before `check_api_simplicity.py` even runs.

---

## The gotcha this built: **all three tools fail silently**

Found while checking that the validator was not a no-op, 2026-07-27. Point `createBrain`'s
`requestBody` at `#/components/schemas/NoSuchSchema` and:

| | Result on a dangling `$ref` | Exit code |
|---|---|---|
| `openapi-spec-validator` | `openapi.yaml: OK` | **0** |
| `openapi-python-client` | `WARNING … Endpoint will not be generated` — the endpoint is *dropped* | **0** |
| `@hey-api/openapi-ts` | `Skipping unresolvable $ref` — the operation is emitted **with a hole** | **0** |

Three tools, three green lights, one SDK missing an operation the contract promises. A naive
"validate then generate" script reports success. `openapi-spec-validator` checks the document
against the OpenAPI JSON Schema; it does **not** resolve local pointers, and neither generator
treats an unresolvable one as fatal.

So the script does not trust any of them:

1. **Every `$ref` is resolved** before anything is generated, and an unresolvable one is fatal with
   the exact JSON path. External (`http://…`) refs are refused outright — a contract that is only
   complete over the network is not reproducible.
2. **`--fail-on-warning`** on `openapi-python-client`, which is off by default. A partial SDK is not
   a green build.
3. **A postcondition**: every `operationId` in the contract must exist as a module in the Python
   tree *and* as an `export const` in `sdk.gen.ts`. This is the one check that catches the
   TypeScript case, because there the file count does not change.

All three were verified to fire, not assumed to (see Verification).

The general form is one the repo already knows: **a check that cannot express failure is not a
check.** "It validated" and "it generated" are both satisfiable by a tool that quietly did less
than you asked.

---

## Why generated and not hand-written

**A hand-written SDK is a second implementation of the contract, and its failure mode is silent.**
It keeps compiling after the server changes, because it encodes its author's belief about the API
rather than the API. The divergence surfaces at a customer, months later, in the one path nobody
wrote a test for. This is the same class of error the repo already spends gates on: a generated
FastAPI schema can only ever agree with itself, which is why
[`check_api_conformance.sh`](../../../scripts/check_api_conformance.sh) fuzzes against the
**checked-in** `openapi.yaml`. An SDK written by hand reintroduces exactly the problem that check
exists to prevent, one layer further out — and further out is worse, because it is the layer the
customer touches.

Four consequences that are worth more than the typing they save:

1. **The contract cannot lie about the client.** The only way for the SDK to be wrong is for the
   contract to be wrong — and the contract has two gates on it already.
2. **An ungeneratable spec is a spec bug, caught at build time.** Validation and `$ref` resolution
   run before generation and the script exits non-zero. A contract that no generator can read is a
   contract no customer can implement either; better to learn that here.
3. **Surface is visible.** See above. Quiet API growth is the thing the simplicity law exists to
   stop, and generation makes the growth show up as a diff in a typed file.
4. **Marginal cost per language is ~zero.** Go, Rust, Kotlin are one more line in the script, not
   one more artifact to keep honest.

The rule that follows: **hand-written code never lives inside `dist/sdk/`.** If an ergonomic wrapper
is ever wanted (`brain.remember(...)` over `rememberRecord(client, ...)`), it is a separate,
committed package that *depends on* the generated one. The generated tree stays a pure function of
the contract, or it is not one.

---

## The generators, and one we declined

| | Tool | Pin | Why |
|---|---|---|---|
| Python | [`openapi-python-client`](https://github.com/openapi-generators/openapi-python-client) | `0.29.0` | Reads OpenAPI 3.1 natively. `httpx` + `attrs`, typed, `py.typed`, sync and async. |
| TypeScript | [`@hey-api/openapi-ts`](https://github.com/hey-api/openapi-ts) | `0.99.0` | Reads 3.1 natively, emits a self-contained fetch client, no runtime deps. |
| — | `ruff` | `0.15.0` | `openapi-python-client` shells out to it to format its output. Pinned into the toolchain so the format step cannot silently skip. |
| — | `openapi-spec-validator` | `0.7.2` | The fail-fast. Dev-only, toolchain-only. |
| — | `typescript` | `5.9.3` | Peer of the TS generator, and the `--strict` typecheck of its output. |

**`openapi-generator` was evaluated and declined**, for two reasons — the first checked here, the
second on the record and *not* verifiable here:

- **It is a JVM tool and this machine has no JVM** — checked: `java -version` fails,
  `/usr/libexec/java_home` finds nothing, no JDK under `/Library/Java/JavaVirtualMachines`. It could
  not be *run*, so a build step using it could not be *verified* — and an unverified build step is
  how a "generated" SDK quietly becomes a hand-written one, the first time somebody cannot run the
  script.
- **Its OpenAPI 3.1 support is partial and works by down-conversion to 3.0.** This one is the
  project's own documented position, not something measured here — with no JVM there was no way to
  test it. If it is out of date, the check is cheap: install a JDK and diff the generated types
  against the shapes this contract leans on — `type: [string, 'null']` (`valid_to`, `text`,
  `abstained`, `next_cursor`, `if_version`), `const` (the `object` discriminator on every resource),
  and 3.1 `examples`. A generator that has to lower the contract to read it produces a client for a
  different contract.

Both chosen tools are open source and read 3.1 as written. If a JVM ever lands on the build host and
a language is wanted that only `openapi-generator` covers, add it as a **third** generator; do not
replace these.

The pins are load-bearing: **a generator upgrade is an SDK change.** Bumping any version above is
its own commit, reviewed against the regenerated diff.

---

## Release policy

1. **The SDK version *is* the contract version.** `package.json` and the Python `pyproject.toml`
   both take `info.version` from `openapi.yaml`. There is no field anywhere to type a different
   number into. An SDK cannot claim a version the contract does not have.
2. **Nothing generated is committed.** `dist/sdk/` is gitignored twice over
   ([`.gitignore`](../../../.gitignore): `dist/` and `dist/sdk/`). Thousands of generated lines in a
   pull request is how a real ten-line diff gets missed, and a generated file in the tree is a file
   somebody eventually edits.
3. **A release is a contract release.** Bump `info.version` → `check_api_simplicity.py` GREEN →
   `check_api_conformance.sh` GREEN → `generate_sdks.sh` GREEN → publish → tag `sdk-v<version>`.
   The gates come first; an SDK is downstream of a contract that passed.
4. **A change that fails the simplicity law never ships**, in any language. The generator will
   happily emit an eighth knob. The gate is what refuses it.
5. **Regenerate on every contract change, not on demand.** The correct time to discover the spec no
   longer generates is the commit that broke it.

**Not yet automated, stated plainly:** the publish step itself. The npm scope `@brain` is not
claimed and the PyPI name is not registered, so `generate_sdks.sh` stops at a built, typechecked,
importable package in `dist/sdk/` and does not push anywhere. Wiring `npm publish` / `twine upload`
into CI is a follow-up to S3, not part of it.

---

## Known warts, not patched

Two name collisions the contract's own nouns cause. Both are real; neither was silently worked
around, because working around them means editing the contract to suit a generator:

- **TypeScript** exports `Record`, `Object` and `Error` as types — all three shadow globals. A
  consumer writing `import type { Record } from '@brain/memory-api'` loses TypeScript's `Record<K,V>`
  utility type *in that file*. Namespace import (`import type * as Brain from …`) avoids it.
- **Python** renames the `filter` knob to `filter_` (`ContextRequest.filter_`), the generator's
  standard escape for a builtin.

Neither is worth renaming a resource over. Both belong in the quickstart (S10) so a stranger meets
them in the docs rather than in a compiler error.

---

## Verification, 2026-07-27

The contract validates as OpenAPI 3.1 — **no errors, nothing patched to make it pass.**
`generate_sdks.sh` runs this before it generates anything; to run it alone (after one
`generate_sdks.sh` has built the toolchain):

```
$ dist/sdk/.toolchain/py/bin/python -m openapi_spec_validator docs/initiatives/memory-api/openapi.yaml
docs/initiatives/memory-api/openapi.yaml: OK
```

**That "OK" was itself checked.** Against a copy with `operationId` set to an integer and
`responses` to a string, the same validator exits 1 with
`Validation Error: 12345 is not of type 'string'` and the failing instance path — so the green above
is a result, not a default.

**And each of the three added guards was made to fire**, by running the shipped code against a
deliberately broken copy of the contract (`#/components/schemas/BrainCreate` →
`#/components/schemas/NoSuchSchema`) and against an amputated pair of SDKs:

```
$ <shipped $ref check> broken.yaml
   UNRESOLVABLE $ref  #/components/schemas/NoSuchSchema  at  $.paths./brains.post.requestBody.content.application/json.schema
exit 1

$ openapi-python-client generate --path broken.yaml --fail-on-warning …
WARNING parsing POST /brains within brains. Endpoint will not be generated.
exit 1                                              # exit 0 without the flag

$ <shipped postcondition> openapi.yaml <sdk tree with create_brain.py deleted and createContext renamed>
   MISSING OPERATION  python: createBrain (create_brain.py)
   MISSING OPERATION  typescript: createContext
exit 1
```

The simplicity law is unaffected by this work — no resource, verb, or knob was added, because an
SDK is not surface:

```
$ .venv/bin/python scripts/check_api_simplicity.py
  resources        5/5   Brain, Context, Memory, Object, Record
  verbs            6/6   accept, create, forget, get, list, remember
  read knobs       7/7
  write knobs      3/3
GREEN — the simplicity law holds.
```

The generators live in `dist/sdk/.toolchain/py`, **not** in the project `.venv`.
`openapi-spec-validator` pins `referencing<0.37.0` and installing it into `.venv` downgrades a
package the conformance gate resolves through. An SDK build must not be able to perturb a gate, so
the toolchain is isolated and the project venv is left exactly as found (`pip check`: no broken
requirements).
