Generated Clients
A Putnami provider declares each operation once. The workspace turns that declaration into typed Go and TypeScript clients, keeps them synchronized, and fails the gate when a consumer bypasses them.
This page is the workspace half of the path. The declaration and call syntax live with each language: Service Clients for Go, Smart Client Library for TypeScript.
declare the provider → generate → register the binding → call
putnami clientgen
putnami clientgen-sync
putnami clientgen-checkOpting a provider in
A provider lists /tooling/clientgen-extension in its putnami.json
extensions, then declares its targets — api.Clients(...) in Go,
clientGenerator(...) in TypeScript. The provider build writes
.gen/clientgen/config.json and the marked OpenAPI and Proto artifacts; those
are the only seam between the two toolchains.
The same-language client is emitted in-app during putnami build. The
cross-language client is emitted by putnami clientgen, which runs the other
toolchain's emitter against the provider's own contract. No extension shells out
to the other.
The three workspace commands
They operate on every project in the Putnami project index, regardless of an impacted consumer-only selection.
| Command | Result |
|---|---|
putnami clientgen |
Generate the configured targets for the selected providers. |
putnami clientgen-sync |
Build all provider contracts without cache reuse, generate every configured target, synchronize tracked files, rebuild every project as a compile gate, and emit the coverage, lineage and adaptation report. |
putnami clientgen-check |
Judge every committed client against its committed manifest and the committed provider contract, and fail on missing coverage, forged or extra generated files, an unclassified external contract, or a handwritten first-party transport. It builds nothing and renders nothing. |
putnami clientgen-adopt |
Rewrite authored imports and construction onto generated bindings, for the entries two emitter manifests can prove on their own. |
Sync and adopt build the provider contracts through the Putnami that launched them, so an impacted consumer-only selection cannot leave a provider on a stale contract. The check spawns no Putnami.
Drift is the generator's verdict
Whether a committed client is what the current contract generates is decided
where the client is written. The tasks that write committed clients —
clientgen-go, clientgen-ts, @putnami/go build-describe and
@putnami/typescript build-generate — declare drift: "fail" on their
client output. The engine compares what each writes (or what a cache hit
restores) with the bytes present immediately before, and fails the task with
the diagnostic generated-output-drift naming the changed files. The worktree
then holds the regenerated client; commit it. A CI checkout of stale committed
bytes fails the same way from the cache restore.
The validate guard
clientgen-check is contributed to putnami validate with workspace-once
activation, so the gate every project already runs verifies the whole
workspace's committed clients exactly once — whatever the selection resolves
to, and whether or not the selected projects declare the extension. A workspace
opts out the ordinary way, by disabling the extension or the job.
validate waits on the !clientgen session barrier: every selected provider
regenerates its targets in place, under the engine's drift judgment, before the
guard reads them; a consumer-only selection plans no generation. The guard
itself reads committed inputs only — the project index, each provider's
committed schema/openapi.json, its committed client.putnami.json manifests
and the two root inventories — so a cold clone and a tree the session just
built reach one verdict. HEAD is never consulted: what CI builds, ships and
caches is the worktree.
What the guard refuses
| Refusal | What to do |
|---|---|
A generator task reports generated-output-drift |
The worktree already holds the regenerated files: commit them |
| A generated file was hand-edited (its hash no longer matches the manifest), or an extra file sits in a generated directory | Revert it; the directory is a Putnami-owned output |
| A committed manifest was cut from a contract other than the committed one | Select the provider (putnami validate --projects <provider>) or run putnami clientgen-sync, then commit |
| A first-party operation has no generated client | Declare the target on the provider, then sync |
| A handwritten transport call reaches a first-party provider | Replace it with the generated binding, or classify it (below) |
An unmarked OpenAPI document is generated without thirdParty: true |
Mark the provider, or declare the contract external |
Every transport callsite the scanner finds is either associated with a first-party provider — which fails, because a generated binding must replace it — or claimed by one of the two committed inventories at the workspace root.
clientgen.framework.json covers a callsite no generated binding can replace,
under one of three statuses:
framework-runtime— the transport a generated binding itself dispatches to. Only@putnami/clientandgo.putnami.dev/clientmay claim it.transport-primitive— a generic transport whose endpoint and contract come from the caller: a form submission, a markdown component, a test harness.pending-provider-contract— a first-party Putnami service whose provider-side client declaration does not exist yet. It must name the operations it waits on and the work that closes it, so it is a versioned state and not an exemption.
clientgen.external.json covers an adapter that speaks a contract someone else
owns — S3, Google Cloud Storage, the GCE metadata server, OAuth 2.0 and OIDC,
OTLP, the OCI distribution spec, the Go module proxy protocol, the npm registry
API — and names that authority.
Both inventories claim exact callsite expressions. An entry never names a folder, a file or a transport as a class: a second call of the same symbol in a listed file is a new callsite and fails, and an entry that matches nothing fails too, so an inventory can only shrink.
Migrating a handwritten client
- Get the provider contract first. Nothing can be generated until the
provider declares its operations and its client targets. Until it does, the
consumer's callsites are
pending-provider-contractinclientgen.framework.json, naming the operations they wait on. - Generate.
putnami clientgen-syncwrites the client package and itsclient.putnami.jsonmanifest. - Adopt what the manifests can prove.
putnami clientgen-adoptrewrites a generated package that moved, and the client constructor or registration call the product contract promises for a service. Both sides need identical contract bytes, service and operations. Go rewrites a qualified selector on the exact identifier the file binds to that import; TypeScript rewrites the specifier and its uses, and refuses a source where the name is also bound locally or used in object-shorthand position. Every file is staged before any file is written, so an adoption that cannot finish migrates nothing. - Work the queue. Argument, credential and call-shape changes are never inferred. The report's adaptation entries name the generated package and binding symbols; the edit is yours.
- Delete the inventory entry. The
pending-provider-contractentry that covered the old callsite now matches nothing, and an entry that matches nothing fails the check — which is how the state closes itself.
Strict generation diagnostics
First-party generation is strict. A semantic the shared client IR cannot carry
fails with the operation named, rather than emitting a client that silently
loses it. Both emitters raise clientgen_unsupported_semantic; the Go code is
api.clientgen_unsupported_semantic.
Declaration shape
| Diagnostic | Cause | Remedy |
|---|---|---|
declares a request body on GET (or HEAD) |
RFC 9110 leaves a GET/HEAD payload undefined and every intermediary may drop it | Move the payload to query parameters, or change the method |
SSE request body is not supported |
A server stream declared a request body | Carry the input in path or query parameters |
declares stream shape …, which this generator does not emit |
The stream mode is not server, client or bidirectional |
Declare one of the three shapes |
<shape> operation over <transports> |
No declared transport can carry that shape — for example a client stream without websocket |
Add a transport that carries the shape, or change the shape |
missing operation contract |
The route carries no x-putnami-client operation metadata |
Register the endpoint through the api plugin so the contract is projected |
Transports
| Diagnostic | Cause | Remedy |
|---|---|---|
websocket encoding "proto" |
Wire v1 keeps proto in the contract; no first-party codec carries WebSocket messages as protobuf |
Declare json encoding on the WebSocket transport |
declares a websocket transport without the putnami.service.v1 subprotocol |
The transport does not speak the published first-party wire | Declare api.WithClientService / api({ client }) so the route negotiates the subprotocol |
declares websocket resume, which only a server stream can honor |
resume on a client or bidirectional stream would replay the caller's own messages |
Remove resume, or make the operation a server stream |
stream reconnect without a provider-declared resume transport |
resilience.stream.reconnect asks for a continuation the provider cannot serve |
Declare resume: true on the WebSocket transport, or drop reconnect |
connect encoding <x> |
The Connect transport declares an encoding neither runtime carries | Declare json or proto |
a connect transport without a fully-qualified protobuf method path |
The descriptor does not name the RPC | Mount the Connect bridge so the provider publishes the method path |
connect encoding "proto" without a published protobuf descriptor |
Nothing describes the messages on the wire | Add the proto plugin so the descriptor travels in the contract |
a connect transport with more than one declared success status |
A Connect response envelope carries one success | Declare one primary success status |
declares a Connect encoding order and publishes no Connect transport |
ConnectEncodings / connectEncodings is set on an operation whose provider mounts no Connect bridge |
Mount the bridge (grpc(), PublishClientConnectTransport), or drop the encoding order |
declares Connect encoding <e> twice in its client encoding order |
The same encoding is named twice | Name each encoding once |
declares Connect encoding <e>, which the mounted bridge does not serve |
The order names an encoding the provider does not serve for this route | Serve it, or narrow the order to the encodings the bridge publishes |
Connect and protobuf semantics
| Diagnostic | Cause | Remedy |
|---|---|---|
declares repeated query parameter <p> on Connect |
A Connect request envelope carries one value per parameter | Declare the parameter once, or drop the Connect transport |
declares a non-object request body on Connect |
A Connect envelope carries the body as an object | Wrap the value in a declared object schema |
declares a nullable member over connect+proto |
proto3 has one absence, so null and absent would become one value | Remove nullable, or drop the proto encoding |
property "<name>", whose protobuf json_name would rename it |
The property name does not survive the proto snake/camel round trip | Rename the property to one that does |
Schemas
| Diagnostic | Cause | Remedy |
|---|---|---|
declares type "integer" without an int32|int64|uint32|uint64 format |
Integer width is declared, never inferred | Declare the format and the exact bounds |
has external $ref / references missing <name> |
The schema points outside the document | Inline the schema, or declare it in the same contract |
array has no items |
An array with no element schema is untyped | Declare the element schema |
has untyped additionalProperties |
A map with no value schema is untyped | Declare the value schema |
has no representable schema type |
The schema declares no type the IR can carry | Declare a supported type |
generated symbol <s> collision between <a> and <b> |
Two operations or schemas would emit the same symbol | Rename one operation or schema |
request content types other than one application/json representation |
Content negotiation is not part of a generated client | Declare one JSON representation, or declare the body binary |
response <status> content types other than one application/json representation |
Same, on the response | Declare one representation per status |
Raw octets
| Diagnostic | Cause | Remedy |
|---|---|---|
declares raw octets without a positive x-putnami-max-bytes bound |
An unbounded octet body cannot be sized by any consumer | Declare maxBytes |
declares raw octets under a JSON media type |
Octets need their own media type | Declare application/octet-stream or another non-JSON media type |
declares format "binary" inside a JSON document |
A JSON string cannot be raw octets | Declare base64 bytes as format: "byte", or declare the whole body binary |
declares nullable raw octets |
An absent body is an empty one | Remove nullable |
constrains raw octets with JSON schema vocabulary |
enum, oneOf, properties and items describe JSON, not octets |
Remove the JSON keywords |
a raw octet payload on a <mode> stream |
Streams carry declared messages, not bodies | Declare a unary operation, or carry the payload as declared messages |
dispatches on <transport>; only rest-json carries octets unchanged |
A Connect envelope would base64-wrap the octets | Declare rest-json first |
a raw octet payload beside multiple declared success variants |
The caller could not tell which variant carries octets | Declare one success status |
response <status> mixing raw octets with another representation |
Same response, two representations | Declare one representation |
response <status> declaring typed headers beside raw octets |
Typed response headers are a JSON-envelope feature | Drop the declared headers |
a raw octet request mixed with another representation |
Same, on the request | Declare one representation |
Provider mode
| Diagnostic | Cause | Remedy |
|---|---|---|
clientgen_first_party_required: provider client generation requires x-putnami-client |
An unmarked OpenAPI document reached first-party generation | Declare the provider through the api plugin, or set thirdParty: true for a contract Putnami does not own |
A first-party Putnami route may not be placed on the external list merely because it uses a non-REST transport.
Determinism and caching
Both project tasks are cacheable, so their cache keys name every input that
decides an emitted byte: the clientgen configuration, the built contract, the
provider package.json and — for the TypeScript target — the Biome
configuration, its extends chain and the EditorConfig beside it, because the
emitter canonicalizes every file it writes with Biome. A configuration this
resolution cannot name as a workspace path fails generation instead of being
silently left out of the key.
Two runs of the same contract produce the same bytes. That is what makes drift a gate failure rather than a diff to review.
Reference
tooling/clientgen-extension/README.md— the extension's own contract, the inventory schemas, and the execution modelprotocols/clientcontract/README.md— the versioned metadata and manifest formats, and the published WebSocket wire