# 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.

Source: https://voxgig.com/howto/map-a-behaviour-to-a-pipeline-stage

- Audience: api-producer
- Level: advanced
- Languages: typescript, ruby
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://github.com/voxgig/sdkgen/blob/main/docs/reference/hooks.md).

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

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Faraday request and response middleware](https://lostisland.github.io/faraday/#/middleware/custom-middleware) | A hand-written Ruby client where one class should see the request and the response | One stack under every call, so a check that needs the operation name infers it from the URL | The client is generated and the behavior must exist in every target |
| [sdkgen six-stage operation pipeline](https://github.com/voxgig/sdkgen/blob/main/docs/explanation/operation-pipeline.md) | A generated client where a behavior needs the operation, the spec or the parsed body | Seven hooks to choose between, and a `PreDone` that a refusal at `PrePoint` never reaches | The behavior acts on each HTTP attempt, which belongs at the transport |
| [Speakeasy SDK hooks](https://www.speakeasy.com/docs/sdks/customize/code/sdk-hooks) | A Speakeasy SDK where request and response hooks are enough | Success and failure arrive in different hooks, and no hook runs after the body is parsed | You 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.

```ruby title="generated/rb/entity/invoice_entity.rb"
      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:

| Stage | Holds | Not yet |
| --- | --- | --- |
| `PrePoint` | The operation and entity names | An endpoint or a spec |
| `PreSpec` | The resolved endpoint in `ctx.out.point` | A spec: `ctx.spec` is undefined |
| `PreRequest` | The spec: method, path, parameters, headers | The fetch request in `ctx.out.request` |
| `PreResponse` | The response status and headers | A parsed body |
| `PreResult` | The parsed body in `ctx.result.body` | Entity data |
| `PreDone` | `ctx.result.resdata`, entity instances for a list | The 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.

```js title="stages.mjs"
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:

```bash
node demo.mjs
```

```text output
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:

```bash
node count.mjs
```

```text output
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`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/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.

```bash
node --test stages.test.mjs
```

```text output
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`](https://docs.ruby-lang.org/en/3.3/ObjectSpace/WeakMap.html), 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.

```bash
ruby test.rb
```

```text output
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](https://github.com/voxgig/sdkgen/blob/main/docs/explanation/operation-pipeline.md)
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](https://github.com/mistralai/client-ts/blob/main/src/lib/sdks.ts), 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.

## Related how-tos

- [Tag SDK calls with session and request ids](/howto/tag-sdk-calls-with-session-and-request-ids)

- [Wrap an SDK transport in a middleware chain](/howto/wrap-an-sdk-transport-with-middleware)

- [Give a Ruby SDK keyword arguments and Faraday middleware](/howto/ruby-sdk-keyword-arguments-and-faraday-middleware)

- [Let a coding agent customize a generated SDK safely](/howto/customise-a-generated-sdk-with-an-agent-safely)

## 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.

[^1]: Speakeasy's [hooks page](https://www.speakeasy.com/docs/sdks/customize/code/sdk-hooks) lists
four lifecycle events: initialization, before request, after success, and after error. The
[`types.ts`](https://github.com/mistralai/client-ts/blob/main/src/hooks/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.

[^2]: The [custom middleware guide](https://github.com/lostisland/faraday/blob/main/docs/middleware/custom-middleware.md)
says a subclass need only implement two methods, `on_request` and `on_complete`. The
[`call` method in the source](https://github.com/lostisland/faraday/blob/main/lib/faraday/middleware.rb)
checks for a third, `on_error`, and calls it with the exception before raising it again.

[^3]: The double call is deliberate on one side. sdkgen 4.34.7's
[error helper](https://github.com/voxgig/sdkgen/blob/main/ts/project/.sdk/tm/ts/src/utility/MakeErrorUtility.ts)
fires `PreUnexpected` to close error paths that never reach `PreDone`, an `rbac` short-circuit
being its own example. The
[shipped audit feature](https://github.com/voxgig/sdkgen/blob/main/ts/project/.sdk/tm/ts/src/feature/audit/AuditFeature.ts)
then keeps a `WeakSet` so that one operation is not logged twice, which is the same arrangement
seen from the receiving end.