How-to › Ship an SDK

How to choose the pipeline stage where an SDK behavior should run#

Place each cross-cutting behavior at the sdkgen stage that holds its input, and prove with forced failures which stages a call runs and which it skips.

Audience
API producer
Level
advanced
Topic
Compose runtime features in an SDK
Languages
TypeScript and Ruby
Verified

Your SDK writes an audit record from a feature hook, and the records are missing for exactly the calls a reviewer asks about: the ones the client refused. The hook runs at PreDone, which a call refused at PrePoint never reaches. The client reported the refusal correctly, which is why nobody noticed the gap.

What you get

You will end up with four behaviors, each placed at the stage that holds its input, and tests that force each kind of failure and assert which stages ran. This is for you if you add features to an sdkgen client.

Short answer

Put each behavior at the earliest sdkgen stage that holds what it needs. That is PrePoint for a permission check, PreRequest for a request header, PreResult for reshaping, and PreDone plus PreUnexpected for an audit record. PreDone runs after an HTTP error or a refused connection, but not after a refusal at PrePoint or a hook that throws. PreUnexpected covers those and can fire twice, so write the record once per call.

You will need

Node 22 or later, Ruby 3.2 or later, and an SDK generated by sdkgen. If you have not written a hook before, add one PreRequest hook to every operation first. Verified 2026-10-15 against Node 22.22.0, Ruby 3.3.6, @voxgig/create-sdkgen 0.30.7 and @voxgig/sdkgen 4.34.7. The sample’s TypeScript and Ruby targets were generated once from api.json by generate.sh and committed, so every command runs offline. The stage list is in sdkgen’s hooks reference.

Voxgig maintains sdkgen. This page compares its operation pipeline with Speakeasy’s SDK hooks and Faraday’s middleware.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Faraday request and response middlewareA hand-written Ruby client where one class should see the request and the responseOne stack under every call, so a check that needs the operation name infers it from the URLThe client is generated and the behavior must exist in every target
sdkgen six-stage operation pipelineA generated client where a behavior needs the operation, the spec or the parsed bodySeven hooks to choose between, and a PreDone that a refusal at PrePoint never reachesThe behavior acts on each HTTP attempt, which belongs at the transport
Speakeasy SDK hooksA Speakeasy SDK where request and response hooks are enoughSuccess and failure arrive in different hooks, and no hook runs after the body is parsedYou reshape parsed results, or need one hook that sees every outcome

Speakeasy splits the end of a call in two: afterSuccess sees a good response before its body is parsed, and afterError sees a failed one.1 Faraday gives one class an on_request and an on_complete, and its source adds an on_error.2 sdkgen has a stage for each step between the operation and the entity data. That precision is what this page uses, at a price: every hook you add runs on every call.

Read what each stage holds

Each stage fires before its step runs. The Ruby target’s pipeline shows the shape: a hook, a step, and an early return when the step returns an error.

      utility.feature_hook.call(ctx, "PrePoint")

      point, err = utility.make_point.call(ctx)
      ctx.out["point"] = point
      return utility.make_error.call(ctx, err) if err

      utility.feature_hook.call(ctx, "PreSpec")

      spec, err = utility.make_spec.call(ctx)
      ctx.out["spec"] = spec
      return utility.make_error.call(ctx, err) if err

      utility.feature_hook.call(ctx, "PreRequest")

The early return is the path a hook’s refusal takes. A failure inside a step, such as an HTTP error, a refused connection, or a missing path parameter, is recorded in the result instead, and the call carries on to PreDone. The TypeScript tests pin all three.

A test probes the TypeScript client at every stage of one load, and pins this table:

StageHoldsNot yet
PrePointThe operation and entity namesAn endpoint or a spec
PreSpecThe resolved endpoint in ctx.out.pointA spec: ctx.spec is undefined
PreRequestThe spec: method, path, parameters, headersThe fetch request in ctx.out.request
PreResponseThe response status and headersA parsed body
PreResultThe parsed body in ctx.result.bodyEntity data
PreDonectx.result.resdata, entity instances for a listThe caller’s return value

Place the four behaviors

The rule is the earliest stage that holds the input. A permission check needs only the operation, so it runs at PrePoint, before any work. A header needs a spec to live in, so it runs at PreRequest. Reshaping needs the parsed body before it becomes entities, so PreResult. An audit record needs the outcome, which no single stage always sees.

export function permissions({ granted = [], rules = {} } = {}) {
  return {
    name: 'permissions',
    PrePoint(ctx) {
      const need = rules[`${ctx.entity?.name}.${ctx.op?.name}`]
      if (need === undefined || granted.includes(need)) return
      const err = ctx.error('permission_denied', `needs "${need}" for ${ctx.entity?.name}.${ctx.op?.name}`)
      ctx.out.point = err
      return err
    },
  }
}

Setting ctx.out.point to an error is how a PrePoint hook refuses, the same move sdkgen’s own rbac feature makes. Each feature is a plain object passed in the client’s extend option, and the runtime calls its method named after the stage. The demo runs one client with all four against a local server:

node demo.mjs
invoice.load inv_1: ok, 12.50
  stages  PrePoint PreSpec PreRequest PreResponse PreResult PreDone
invoice.list: ok, 12.50 0.99
  stages  PrePoint PreSpec PreRequest PreResponse PreResult PreDone
invoice.load inv_broken: LedgerSDK: load: request: 500: Internal Server Error
  stages  PrePoint PreSpec PreRequest PreResponse PreResult PreDone PreUnexpected PreUnexpected
invoice.remove inv_2: LedgerSDK: remove: needs "invoices:delete" for invoice.remove
  stages  PrePoint PreUnexpected PreUnexpected
invoice.load, nothing listening: LedgerSDK: load: fetch failed
  stages  PrePoint PreSpec PreRequest PreResponse PreResult PreDone PreUnexpected PreUnexpected

requests the server saw
  GET    /v1/invoices/inv_1       x-request-id: req_1
  GET    /v1/invoices             x-request-id: req_2
  GET    /v1/invoices/inv_broken  x-request-id: req_3

audit records, one per call
  invoice.load    ok     status 200
  invoice.list    ok     status 200
  invoice.load    error  status 500  request_status
  invoice.remove  error  status null permission_denied
  invoice.load    error  status -1   ECONNREFUSED

The 500 and the dead port both reach PreDone. The refused remove stops after PrePoint, sends nothing, and goes straight to PreUnexpected, which fires twice: once from the error helper and once from the operation’s own catch.3 The hooks reference describes PreUnexpected as firing when an exception escapes the pipeline. In 4.34.7 it fires for every failed call, including the 500 and the dead port, because the error helper fires it before raising. The reshape added amount to both reads and left the error alone.

Record every call exactly once

The same five calls, with the audit record written from four places:

node count.mjs
5 calls: 2 succeed, 1 HTTP 500, 1 refused at PrePoint, 1 with nothing listening
  PreResult only                          4 records  no record of the refused remove
  PreDone only                            4 records  no record of the refused remove
  PreDone and PreUnexpected, unguarded   10 records
  PreDone and PreUnexpected, guarded      5 records

PreResult and PreDone miss the same call. Adding PreUnexpected without a guard writes ten records for five calls. The guard is a WeakSet of the call’s context object, which lets each context be collected once the call is over. sdkgen’s shipped audit feature uses one for the same reason.

Check it worked

Thirteen TypeScript tests force a 500, a refusal, a dead port, a missing path parameter, and a hook that throws. One passes the same ctrl object to two calls. The SDK keeps a call’s error on that object, so an audit that read the error from there recorded the first call’s failure against the second call’s success.

node --test stages.test.mjs
1..13
# tests 13
# suites 0
# pass 13
# fail 0
# cancelled 0
# skipped 0
# todo 0

The Ruby target runs the same stages in the same order, and seven minitest tests pin them, a refused connection included. Its audit guard is an ObjectSpace::WeakMap, which compares keys by identity and holds them weakly. A Set would keep every context the client ever audited, and a test counts the contexts still alive after forty calls.

ruby test.rb
7 runs, 20 assertions, 0 failures, 0 errors, 0 skips

When it goes wrong

The feature never runs. The generated README and sdkgen’s pipeline explanation both describe a feature as an object with a hooks map. The 4.34.7 runtime calls a method named after the stage on the feature itself and never reads the map, as a test pins. Put the methods on the object.

The caller gets your TypeError instead of the SDK’s error. A hook threw, and the pipeline treated it as the failure: PreDone was skipped and PreUnexpected fired once. Guard reshaping code with ctx.result.ok, because a failed call reaches PreResult carrying its error body.

Setting a header at PreSpec throws, because ctx.spec does not exist yet. Move it to PreRequest.

A request id minted in a Speakeasy beforeRequest hook changes on every retry. Mistral’s Speakeasy-generated TypeScript client calls beforeRequest and the after hooks inside its retry loop, once per attempt. sdkgen’s hooks reference says a hook handles one operation while the transport handles every attempt, so an id set at PreRequest holds across the attempts.

When not to do this

Do not treat a PrePoint permission check as a security boundary. It saves a round trip and gives a clear error, but the caller can construct the client without it, so the server must still enforce the rule.

Do not put per-attempt work in a stage hook. Timing each attempt, retrying, or answering from a cache belongs at sdkgen’s transport seam, a feature declared with transport: 'wrap', which the hooks reference says sees every HTTP attempt.

Do not add a hook for a behavior that one call site needs. Every feature in extend runs on every operation of the client, and one that waits on the network adds that wait to each call.

Last verified

Verified 2026-10-15 against Node 22.22.0, Ruby 3.3.6, @voxgig/create-sdkgen 0.30.7 and @voxgig/sdkgen 4.34.7. Every output block is what the command preceding it printed. Speakeasy and Faraday were not run; their hooks are taken from the documentation and source linked on this page.

Footnotes

  1. Speakeasy’s hooks page lists four lifecycle events: initialization, before request, after success, and after error. The types.ts in a published Speakeasy-generated SDK declares five hook interfaces. The fifth, beforeCreateRequest, runs before the Request object exists, because its own comment explains that some changes, such as the URL, cannot be made to a request once it is built. ↩︎ Back to text

  2. The custom middleware guide says a subclass need only implement two methods, on_request and on_complete. The call method in the source checks for a third, on_error, and calls it with the exception before raising it again. ↩︎ Back to text

  3. The double call is deliberate on one side. sdkgen 4.34.7’s error helper fires PreUnexpected to close error paths that never reach PreDone, an rbac short-circuit being its own example. The shipped audit feature then keeps a WeakSet so that one operation is not logged twice, which is the same arrangement seen from the receiving end. ↩︎ 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.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.