Codegen Pipeline Focus Deep-Dive

From api.schema.json to four language-native SDKs, fully reviewable.

Every typed surface in every SDK — every method stub, every request/response struct, every event record — originates in one JSON Schema. This page traces the data path: schema → normalisation → AST → per-language emitter → formatter → committed source file. It is the backbone that keeps four SDKs behaviourally identical.

📚 What is this & why does it exist?

A tightly-scoped TypeScript program that reads one schema and writes four languages' worth of RPC bindings. It is invoked by npm run codegen. Output is committed to the repo, so PRs can show readable diffs like "+session.setModel method in all four SDKs" rather than "schema changed; trust us."

💡 Real-world analogy

Think of a mint that stamps coins in four currencies from a single die. The die (schema) defines the portrait and inscription; the stamps (emitters) apply currency-specific details: the euro version shows "EUR", the dollar shows "USD", all from the same underlying engraving. When the portrait is updated (schema change), every currency gets the update in one mint run — not four independent engravings hoping they match.

🎯 The Problem

Four SDKs in four languages need to stay in lockstep with a JSON-RPC schema that evolves monthly. Humans cannot reliably keep 4 sets of type declarations in sync. Common failure modes without codegen: Node adds a parameter, Python is missed; Go emits a number where the schema demands an integer; .NET misses a nullable and crashes when the field is null. Each of these is a silent wire-level bug.

The codegen pipeline removes the human-memory dependency. Any schema change produces a diff in all four SDKs; if the diff is unexpected, the PR author fixes the schema (or the emitter). If the diff is missing, CI fails.

🔧 The Approach

A three-stage pipeline:

  1. Normalise the schema so per-language emitters see a clean AST (inline small $refs, tag integer vs number, canonicalise nullable, patch "required-but-nullable" fields).
  2. Walk the normalised tree once per language, writing idiomatic code via string concatenation + per-language name/quote helpers.
  3. Format the output with each language's canonical formatter so diffs are deterministic.

Corrections are applied before normalisation so they look like schema edits rather than emitter overrides — this keeps emitters simple and the correction footprint obvious.

🔄 Why Not Alternatives?

ApproachProsConsWhy Not Chosen
OpenAPI GeneratorExisting tool, broad language supportHeavy, template-driven, hard to customise idioms per languageOutput doesn't look hand-written enough; too many dependencies
Protocol Buffers + protocMature, provenRequires switching wire format; loses JSON debuggability; binds to protoc toolchainJSON is core to the product; would be a ground-up rewrite
Handlebars/Mustache templatesFamiliar templatingLogic-in-template hell for complex casesString concat + helpers is clearer than templates when logic is non-trivial
Build-time generation (not committed)No stale-output riskCI needs Node to build Go; debuggers step into source-mapped gen; reviewers can't see diffsCommitted output wins on reviewability
Hand-written in each SDKPerfect idiomGuaranteed driftCatastrophic at scale

Pipeline Diagram

flowchart TD
  S[api.schema.json
from @github/copilot/schemas] C[corrections/*.ts,.json
patches] P1[inlineSmallRefs] P2[tagIntegers] P3[canonicaliseNullable] P4[fixNullableRequiredRefs] AST[Shared AST
methods / types / events] FMT[Formatter
prettier / black / gofmt / dotnet-format] subgraph emit["Emitters"] E1[typescript.ts] E2[python.ts] E3[go.ts] E4[csharp.ts] end S --> P1 C --> P1 P1 --> P2 --> P3 --> P4 --> AST AST --> E1 --> FMT --> O1[nodejs/src/generated/rpc.ts] AST --> E2 --> FMT --> O2[python/copilot/_generated_rpc.py] AST --> E3 --> FMT --> O3[go/rpc/generated_rpc.go] AST --> E4 --> FMT --> O4[dotnet/src/Generated/Rpc.cs]

Step-by-Step

Read the schema

// scripts/codegen/index.ts
const schemaPath = process.env.SCHEMA_PATH ??
    path.join(__dirname, "../../nodejs/node_modules/@github/copilot/schemas/api.schema.json");
const raw = JSON.parse(fs.readFileSync(schemaPath, "utf8"));

Why relative to node_modules? The schema ships with the @github/copilot package, so versions stay paired. Environment override allows schema forks for experiments.

Apply corrections

const corrected = applyCorrections(raw, loadCorrections());
// corrections can: override a type, add description, delete a method, pin a field to integer

Corrections are scoped by JSONPath-like selectors. Each one has a comment explaining why, and a target schema version after which it can be removed.

Normalise

const normalised = fixNullableRequiredRefsInApiSchema(postProcessSchema(corrected));

postProcessSchema does the generic normalisation. fixNullableRequiredRefsInApiSchema handles a specific schema pattern that maps inconsistently across languages: a required field typed as [T, null] is a nullable-but-always-present field. Each emitter needs to represent this as T? / Optional[T] / *T.

Build AST

const ast = buildAst(normalised);
// ast.methods[n] = { name, params, result, description }
// ast.types[n]   = { name, fields, kind: "record" | "union" | "enum" }
// ast.events[n]  = { tag, fields }

The AST drops JSON-Schema-specific syntax in favour of a stable shape the emitters can pattern-match against.

Emit per language

await emitTypeScript(ast, { out: "nodejs/src/generated/rpc.ts" });
await emitPython(ast, { out: "python/copilot/_generated_rpc.py" });
await emitGo(ast, { out: "go/rpc/generated_rpc.go", skipEvents: true });
await emitCSharp(ast, { out: "dotnet/src/Generated/Rpc.cs" });
await emitCSharp(ast, { out: "dotnet/src/Generated/SessionEvents.cs", eventsOnly: true });

Go skips events because rpc/result_union.go is hand-written — the emitter can't yet produce idiomatic Go union handling.

Format

await runFormatter("prettier", "nodejs/src/generated/rpc.ts");
await runFormatter("black",    "python/copilot/_generated_rpc.py");
await runFormatter("gofmt",    "go/rpc/generated_rpc.go");
await runFormatter("dotnet",   "dotnet/src/Generated/*.cs");

Missing formatters are skipped with a warning (useful in local dev; CI has them all).

Check drift

npm run codegen -- --check
# Generates into .tmp/, diffs against committed files, exits 1 on any difference.

CI runs this. Any schema edit that wasn't accompanied by a regenerated commit fails the PR.

Type Mapping Cheatsheet

JSON SchemaTypeScriptPythonGoC#
stringstringstrstringstring
integernumberintint64long
numbernumberfloatfloat64double
booleanbooleanboolboolbool
[T, null]T | nullOptional[T]*TT?
array[T]T[]list[T][]TIReadOnlyList<T>
duration msnumberinttime.Duration*TimeSpan**
oneOf (tagged)discriminated unionUnion[...] + matchtype + tag dispatchpolymorphic converter

* Go could emit time.Duration but currently uses int64 ms; future emitter work. ** C# uses MillisecondsTimeSpanConverter to bridge wire ms to TimeSpan.

⚖️ Complexity & Tradeoffs

The whole pipeline runs in ~1.5s for all four languages. Not optimised — emitters use string += patterns and run sequentially. Could be parallelised but there's no pressure.

The expensive cost isn't time but intellectual: each emitter must understand its target language's idioms deeply. Adding a fifth language (e.g., Rust, Java) would mean writing a fifth emitter of ~500-800 lines. The shared infrastructure (schema loading, AST, corrections) is reused, so it's not starting from zero — but it's not free either.

💥 Failure Recovery

Codegen is not a runtime path — no user ever runs it. Failures manifest as: (1) local npm run codegen exits non-zero; (2) CI --check step fails the PR; (3) compile failures in whichever SDK consumed a malformed output. The repo's scripts/check-api-surface.ts is a secondary guard — it compares the Node public API to a committed manifest, catching accidentally-exported renames.

Edge Cases

ScenarioBehaviourWhy
Schema field renamedCodegen updates all four SDKs; compile fails in user codeIntentional — wire-breaking renames should break compile
New enum variantAll four SDKs gain the variant; user switch statements with default keep workingForward-compat pattern that users opt into
Correction file targets removed pathWarning; codegen continuesStale corrections are a normal intermediate state
Target file locked on WindowsEmitter retries with backoffIDE file locks in dev environments
Go emitter on a type with unionsSkipped; hand-written result_union.go covers itGo idiom for unions is not yet automatable

Configuration

KnobDefaultPurpose
SCHEMA_PATHpaired Node installOverride schema
--checkoffDrift detection for CI
--languageallRegenerate one language only
Corrections dirscripts/codegen/corrections/Patch overrides
Formatter timeout30sSafety on slow dotnet-format
Reserved-word listper-emitterRename collisions (class→class_)
Skip eventsGo emitter: trueGo unions are hand-written
Emit STJ polymorphic converterC# emitter: trueRequired for event deser

Directory Map

scripts/codegen/
scripts/codegen/
├── index.ts               ← orchestrator entry
├── utils.ts               ← name helpers, AST walkers
├── postProcess.ts         ← postProcessSchema + fixNullableRequiredRefs
├── typescript.ts          ← TS emitter
├── python.ts              ← Py emitter
├── go.ts                  ← Go emitter
├── csharp.ts              ← C# emitter
└── corrections/           ← patches