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-check

Opting 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/client and go.putnami.dev/client may 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

  1. 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-contract in clientgen.framework.json, naming the operations they wait on.
  2. Generate. putnami clientgen-sync writes the client package and its client.putnami.json manifest.
  3. Adopt what the manifests can prove. putnami clientgen-adopt rewrites 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.
  4. 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.
  5. Delete the inventory entry. The pending-provider-contract entry 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 model
  • protocols/clientcontract/README.md — the versioned metadata and manifest formats, and the published WebSocket wire