# How to let a coding agent customize a generated SDK safely

> Point the agent at the levers under .sdk/, never at the generated tree, and prove with doctor and a regeneration diff that only the intended files move.

Source: https://voxgig.com/howto/customise-a-generated-sdk-with-an-agent-safely

- Audience: library-maintainer
- Level: advanced
- Languages: typescript
- Verified: 2026-09-25
- Published: 2026-09-25

## Short answer

Tell the agent the generated tree is build output and its changes go under .sdk/: a declaration in model/project.aontu, a component of its own, or a feature added with the CLI. Run voxgig-sdkgen doctor before you regenerate, because a fork of a shipped component passes every test until the next resync reverts it. Then regenerate into a copy and diff, so the only files that move are the ones the change was meant to move.

---
## You will need

Node 22 or later, an sdkgen project with at least one target, and a coding agent that reads
`AGENTS.md`. Verified 2026-09-25 against Node 22.22.2 and `@voxgig/sdkgen` 4.28.0, in a project
scaffolded with `@voxgig/create-sdkgen` 0.28.0. The toolchain declares Node 24 in `engines` and
installed under Node 22 with a warning; every command here ran on 22. The project is committed
beside the samples, so the commands run offline against a toolchain installed once at the top of
the directory.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [OpenAPI Generator ignore file and template overrides](https://openapi-generator.tech/docs/customization/) | The generator is OpenAPI Generator and the change is a whole file you want to own, or a template you want to alter | A file listed in `.openapi-generator-ignore` is never written again, so it is hidden from every later improvement, and an overridden template tracks upstream by hand | The change is a decision about the project rather than a file, which the generator has no place for |
| [sdkgen customization levers and `doctor`](https://voxgig.com/sdk/custom) | The generator is sdkgen and the change belongs in the model, a component, a feature, or a template | A model language to learn, an `.sdk/` tree the agent has to be steered into, and a template edit that the next resync reverts | The SDK is not generated by sdkgen, or the customization is one file in one language for good |
| [Speakeasy custom code](https://www.speakeasy.com/docs/sdks/customize/code/custom-code/custom-code) | The generator is Speakeasy, the edit is small and sits anywhere in the output, and the project is in Git | A three-way merge on every regeneration, conflict markers when the generator and the edit touch the same lines, and a Git repository as a hard requirement | The edits are large or many, where every regeneration turns into a merge to resolve |

Voxgig maintains sdkgen. This page compares its customization levers and `doctor` with OpenAPI
Generator's ignore file and template overrides, and with Speakeasy's custom code. The three
answer the same question, where an edit lives so that regeneration keeps it, with three
mechanisms. An ignore file hides a file from the generator for good. A merge negotiates every
regeneration. A separate tree of levers keeps the edit out of the output entirely, and needs the
agent steered into it.

## Point the agent at the levers, not the output

sdkgen writes the first instruction itself. `npm create @voxgig/sdkgen` scaffolds a project with
an `AGENTS.md` at the root, a thin `CLAUDE.md` that points at it, and a guide per target, and the
root guide says where an edit goes:

```text title="meterco-sdk/AGENTS.md"
| Layer | Path | Nature |
| --- | --- | --- |
| **Templates** | `.sdk/tm/<lang>/` | Plain target-language source, copied verbatim with placeholder substitution. Edit when the file is the **same for every API** (transport, base classes, runtime, utilities). |
| **Components** | `.sdk/src/cmp/<lang>/` | TypeScript that **generates** source by walking the model. Edit when the file's shape **depends on the API** (entity classes, the constructor, README, tests). |

> Decision rule: *same for every API → template; depends on the API →
> component.*
```

That guide is generated with everything else, so an edit to it lasts until the next generate. A
rule of your own belongs in a file the generator does not write, which for Claude Code is a
rules file under [`.claude/rules/`](https://code.claude.com/docs/en/memory). The rule worth
adding is the one this page is about: run `doctor` before you regenerate, and never edit `ts/`.

The levers sit under `.sdk/`, and the
[customization page](https://voxgig.com/sdk/custom) orders them by preference. The model comes
first: `model/project.aontu` is the overlay the scaffold creates once and never overwrites, and
every generate re-reads it. Then a component of the project's own, beside the stock ones in
`src/cmp/ts/`. Then a feature, which the CLI installs from the ones the generator ships. A
template under `tm/ts/` is the last resort, because `target add` refreshes templates from their
source and an edit there is reverted with them.

## Make the change where regeneration re-reads it

The demo copies the committed project four times and lets an agent loose on each copy: one wrong
place, then three right ones. The component the agent writes walks the same model the stock
components walk, and writes one more file into the target:

```ts title="agent-edits/Manifest_ts.ts"
import { cmp, each, File, Content } from '@voxgig/sdkgen'
import { KIT, getModelPath } from '@voxgig/apidef'

// A project-owned component. It writes a manifest of the entities and their
// operations beside the generated SDK, on every generate, by walking the same
// model the stock components walk. The scaffold never shipped this file, so
// `voxgig-sdkgen doctor` reports it as additive and `target add` leaves it alone.
const Manifest = cmp(function Manifest(props: any) {
  const { model } = props.ctx$
  const entity = getModelPath(model, `main.${KIT}.entity`)
  const entities = each(entity).map((ent: any) => ({
    name: ent.name,
    ops: Object.keys(ent.op || {}).sort(),
  }))
  File({ name: 'manifest.json' }, () => {
    Content(JSON.stringify({ entities }, null, 2) + '\n')
  })
})

export { Manifest }
```

The demo copies it into place, wires it into the project's own `src/Root.ts`, which `target add`
never touches, and appends the version to the overlay:

```js title="agent-edits.mjs"
    copyFileSync(join(HERE, 'agent-edits/Manifest_ts.ts'), join(copy.dir, '.sdk/src/cmp/ts/Manifest_ts.ts'))
    const root = join(copy.dir, '.sdk/src/Root.ts')
    writeFileSync(root, readFileSync(root, 'utf8')
      .replace("import { BuildSDK } from './BuildSDK'", "import { BuildSDK } from './BuildSDK'\nimport { Manifest } from './cmp/ts/Manifest_ts'")
      .replace('        Main({ target })\n', '        Main({ target })\n        Manifest({ target })\n'))
    appendFileSync(join(copy.dir, '.sdk/model/project.aontu'), readFileSync(join(HERE, 'agent-edits/project.aontu'), 'utf8'))
    generate(copy.dir)
```

```aontu title="agent-edits/project.aontu"
main: kit: target: ts: publish: version: '0.2.0'
```

```bash
node agent-edits.mjs
```

```text output
@voxgig/sdkgen 4.28.0, project meterco-sdk with one target, ts

A. the agent appends to ts/src/MetercoEntityBase.ts
  before regenerating
    changed  src/MetercoEntityBase.ts
  after npm run generate
    nothing differs from the committed output

B. the agent adds .sdk/src/cmp/ts/Manifest_ts.ts, wires it in .sdk/src/Root.ts, and declares a version in .sdk/model/project.aontu
  after npm run generate
    added    manifest.json
    changed  package.json
    changed  src/Config.ts
  ts/manifest.json
    {
      "entities": [
        {
          "name": "meter",
          "ops": [
            "create",
            "list",
            "load"
          ]
        }
      ]
    }
  voxgig-sdkgen doctor, exit 0
    INFO: sdkgen                 doctor-finding       additive (project-owned, not drift): src/cmp/ts/Manifest_ts.ts
    INFO: sdkgen                 doctor-end           .sdk matches the scaffold (1 additive)

C. the agent appends to .sdk/src/cmp/ts/Entity_ts.ts, a component the scaffold ships
  voxgig-sdkgen doctor, exit 1
    INFO: sdkgen                 doctor-finding       FORKED (will be reverted by `target add`): src/cmp/ts/Entity_ts.ts
    INFO: sdkgen                 doctor-end           .sdk has drifted: 1 forked, 0 edited, 0 stale, 0 missing
  voxgig-sdkgen target add ts, exit 0
  voxgig-sdkgen doctor again, exit 0
    INFO: sdkgen                 doctor-end           .sdk matches the scaffold (0 additive)
  the edit is gone

D. the agent runs voxgig-sdkgen feature add retry, exit 0
  after npm run generate
    added    src/feature/retry/AGENTS.md
    added    src/feature/retry/CLAUDE.md
    added    src/feature/retry/RetryFeature.ts
    changed  AGENTS.md
    changed  README.md
    changed  REFERENCE.md
    changed  src/Config.ts
    changed  src/Schema.ts
  voxgig-sdkgen doctor, exit 0
    INFO: sdkgen                 doctor-end           .sdk matches the scaffold (0 additive)
```

Four results, one per copy. In A the edit under `ts/` is there before `npm run generate` and gone
after it. Nothing merged: sdkgen 4.28.0 rewrote the file from the model, and the only sign that
anything happened is a diff that came back empty.[^1] In B exactly three files moved. The
manifest is new, and the package manifest and `Config.ts` changed because the version
declaration reaches both. `doctor` calls the component additive and exits 0.

C is the fork. The same kind of edit, appended to a component the scaffold ships, is reported as
`FORKED` with the sentence that matters: it will be reverted by `target add`. The resync then
does exactly that, and the second `doctor` finds the project clean and the edit gone. Nothing
about the edit was wrong. It was in a file the generator owns.

D is retry, the behavior from the problem statement, added the way the generator intends. One
CLI command installs the feature's model and template. The generate writes `RetryFeature.ts`
into the target, and the configuration and schema learn its options. `doctor` reports no drift,
because a feature installed by the CLI is scaffold content.

## Run doctor before you regenerate

The wrapper runs the command with the project's `.sdk/` as the working directory, strips the
clock from the log lines, and passes the exit code through.

```js title="lib.mjs"
// The generator's log lines open with a clock. Everything after it is stable.
const CLOCK = /^\[\d\d:\d\d:\d\d\.\d{3}\] /

function sdkgen(projectDir, args) {
  ensureToolchain(projectDir)
  const r = spawnSync(process.execPath, [SDKGEN_BIN, ...args], { cwd: join(projectDir, '.sdk'), encoding: 'utf8' })
  const lines = `${r.stdout}${r.stderr}`.split('\n').map((l) => l.replace(CLOCK, '').trimEnd()).filter(Boolean)
  return { code: r.status, lines }
}
```

```bash
node doctor.mjs
```

```text output
@voxgig/sdkgen 4.28.0, doctor over meterco-sdk/.sdk
  INFO: sdkgen                 doctor-start
  INFO: sdkgen                 doctor-end           .sdk matches the scaffold (0 additive)
exit 0
```

The [CLI reference](https://raw.githubusercontent.com/voxgig/sdkgen/main/docs/reference/cli.md)
lists six categories.[^2] Forked, edited, stale, and missing fail the run, because a resync would
revert or remove each of them. The `additive` and `unwired` findings are reported and never fail, because adding
a component is the supported way to extend a target and opting out of a root component is
legitimate. Version 4.28.0 reports two categories the reference omits: superseded output, and
a model file that nothing includes. Both fail the run, and the end line counts neither, so read
the findings printed before it. The exit code is the gate: put `doctor` in the job that runs before
`generate`, and a fork stops in CI rather than in the next regeneration.

One condition to guard. With nothing installed beside `.sdk/`, version 4.28.0 prints its start
line, compares nothing, and exits 0. The wrapper here treats a run with no `doctor-end` line as
a failure, and a test pins the behavior so the gap stays visible.

## What the ignore file and the merge cost instead

OpenAPI Generator keeps an edit by not writing the file. A path in `.openapi-generator-ignore`,
in the root of the output directory, is skipped on every later run.[^3] The
[templating page](https://openapi-generator.tech/docs/templating/) lets you override a built-in
template with a copy in a directory passed as `-t`. Both are simple and both are forks in
disguise. The ignored file never sees a generator improvement again, and the copied template has
to be compared with the embedded one by hand on every upgrade.

Speakeasy keeps the edit by merging. Custom code performs a three-way merge on generation, over
the pristine output of the previous run, the file on disk, and the fresh output. Conflicts
arrive as Git conflict markers staged in the index.[^4] The
[ignore file](https://www.speakeasy.com/docs/sdks/customize/code/custom-code/genignore) is the
same escape hatch as OpenAPI Generator's, with the same dead-code warning attached. A merge is a
safety net for an edit that has already landed in the output. The levers keep it from landing
there at all, which is the difference an agent needs, because an agent does not resolve a
conflict marker well.

## Check it worked

Nine tests. Every one that edits or regenerates works on a throwaway copy of the committed
project.

```bash
node --test customize.test.mjs
```

```text output
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
```

`# pass 9`. The third test regenerates the customized copy a second time and asserts the diff is
unchanged. That is the property the page promises: a change made in the levers is stable across
regenerations, not merely present after the first.

## When it goes wrong

`doctor` prints its start line and nothing else, and exits 0. There is no toolchain beside
`.sdk/`, so it found no scaffold to compare against and reported no comparison. Install the
project's dependencies, or link them as the samples do, and fail the job when the end line is
missing.

`doctor` reports a fork in a file nobody edited. The toolchain moved: `@voxgig/sdkgen` was
upgraded, and the scaffold it ships differs from the copy in the project. Move any decision in
that file into `model/project.aontu`, then resync with `target add`, and the report goes clean.

The new component compiles but writes nothing to `ts/`. It is not called. A component runs only
when `src/Root.ts` calls it inside the target's folder, and `tsc` is happy to compile a function
nobody invokes.

The agent's rule in `AGENTS.md` disappeared after a generate. The root guide is generated by the
`AgentGuideTop` component, so it is output. Put the rule in a file the generator does not write.

## When not to do this

Do not use sdkgen's `doctor` as the review of the agent's change. It reports drift from the
scaffold and nothing else, so an additive component that writes nonsense passes it. Review the
component, run the generated SDK's own tests, and keep `doctor` for the question it answers.

Do not let the agent answer a `FORKED` finding with `target add`. The resync reverts the file,
which deletes the work rather than relocating it. Move the decision into the model or into a
component of the project's own first, and resync after.

Do not hand-edit a template under `tm/` for a change you want to keep. The customization page
says a durable template change rides your own sdkgen package or goes upstream, and the run
above shows why: the next resync puts the shipped file back.

Do not apply this page to an SDK generated by OpenAPI Generator or Speakeasy. Their mechanisms
are the ignore file and the merge, and steering an agent into an `.sdk/` tree that does not
exist helps nobody.

## Related how-tos

- [Fail a build when a generator customization becomes a fork](/howto/audit-generator-customisations-for-drift)

- [Preview a regeneration as a diff before writing files](/howto/preview-a-regeneration-as-a-diff)

- [Generate your first SDK with sdkgen](/howto/first-sdk-with-sdkgen)

- [Stop a coding agent inventing endpoints not in the spec](/howto/stop-a-coding-agent-inventing-endpoints)

## Last verified

Verified 2026-09-25 against Node 22.22.2, `@voxgig/sdkgen` 4.28.0, and `@voxgig/create-sdkgen`
0.28.0. Every output block is what the command preceding it printed. The project was scaffolded
once, with network access, and committed without its toolchain; the commands install nothing
and regenerate in temporary copies.

[^1]: Two of sdkgen's own documents describe regeneration differently. The
[customization page](https://voxgig.com/sdk/custom) says that by default regeneration
diff-merges, three-way, and that a project can opt for plain overwrite. The repository's
[explanation page](https://raw.githubusercontent.com/voxgig/sdkgen/main/docs/explanation/regeneration-overwrite.md)
is titled Regeneration is overwrite, not merge, and records `merge: false` as the decision. It
lists three failure modes the merge had caused, from stale files kept to conflict markers
written into an index file. The run on this page overwrote. Of the two documents, the one that matched the
bytes is the one that lives beside the code.

[^2]: The [reference](https://raw.githubusercontent.com/voxgig/sdkgen/main/docs/reference/cli.md)
explains why the check is a command and not a `diff -r` against the scaffold. `target add`
writes template masters with substitution partly applied and inconsistently, so most of what a
naive diff reports is not an edit at all. An edited template is therefore compared after the
same substitutions `target add` applied. The tool that made the mess is the only one that can
read it.

[^3]: The [customization page](https://openapi-generator.tech/docs/customization/) introduces
the ignore file as similar to `.gitignore` or `.dockerignore`, and requires it in the root of the
output directory. It then offers `--ignore-file-override`, described as a complete override of
that file, and `--openapi-generator-ignore-list`, which writes entries into it during generation.
A file for telling the generator what not to write, and two flags for telling it what to write
into that file.

[^4]: Speakeasy's [custom code page](https://www.speakeasy.com/docs/sdks/customize/code/custom-code/custom-code)
requires a Git repository, because the feature uses Git under the hood, and adds that direct
interaction with Git is not necessary. When a conflict occurs it adds the markers and stages the
conflict in the Git index, so that editors and `git status` recognize it, and the resolution ends
with `speakeasy run --skip-versioning`. In CI no prompt appears, a conflict fails the job, and the
GitHub Action rolls back to the last working generator version. A merge conflict belongs to Git,
then, even for a tool that promised you would never have to touch Git.