How-to › Use AI to do the integration

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.

Audience
Library maintainer
Level
advanced
Topic
Write an integration with a coding agent
Languages
TypeScript
Verified

The agent adds a retry budget to the generated TypeScript SDK, the tests pass, and the change ships. Two weeks later somebody regenerates after a spec change and the retry is gone, because the agent put it in ts/src/, the tree the generator rewrites from scratch. Nothing failed at the time, which is why nobody caught it.

What you get

You will end up with an agent that customizes an sdkgen project in the places a regeneration re-reads. A doctor run refuses a fork before it ships, and a diff shows only the intended files moving. This is for you if you maintain a generated SDK that an agent edits.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
OpenAPI Generator ignore file and template overridesThe generator is OpenAPI Generator and the change is a whole file you want to own, or a template you want to alterA 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 handThe change is a decision about the project rather than a file, which the generator has no place for
sdkgen customization levers and doctorThe generator is sdkgen and the change belongs in the model, a component, a feature, or a templateA model language to learn, an .sdk/ tree the agent has to be steered into, and a template edit that the next resync revertsThe SDK is not generated by sdkgen, or the customization is one file in one language for good
Speakeasy custom codeThe generator is Speakeasy, the edit is small and sits anywhere in the output, and the project is in GitA 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 requirementThe 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:

| 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/. 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 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:

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:

    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)
main: kit: target: ts: publish: version: '0.2.0'
node agent-edits.mjs
@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.

// 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 }
}
node doctor.mjs
@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 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 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 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.

node --test customize.test.mjs
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.

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.

Footnotes

  1. Two of sdkgen’s own documents describe regeneration differently. The customization page says that by default regeneration diff-merges, three-way, and that a project can opt for plain overwrite. The repository’s explanation page 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. ↩︎ Back to text

  2. The reference 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. ↩︎ Back to text

  3. The customization page 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. ↩︎ Back to text

  4. Speakeasy’s custom code page 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.