Hook plugins are usually short-lived subprocesses that handle a single extension point per invocation. Restish writes one CBOR data item to the plugin's stdin, reads either one CBOR reply or raw formatter output, and then the plugin exits.
Formatter plugins are the one exception: they run as a short formatter session so they can maintain output state across paginated and event-stream renders.
This model is used for:
authrequest-middlewareresponse-middlewareloaderformatter
Some extensions need access to Restish's request and response model but do not need to own an entire command lifecycle. Typical examples include:
- adding auth headers from external credentials
- injecting request headers
- mutating or suppressing normalized responses
- teaching Restish about a new API description content type
- adding a new output format
Those use cases should stay simple to implement and safe to reason about. Most of them do not need a persistent session or multi-message protocol.
Formatter plugins turned out to be the boundary case. One-shot formatter hooks work for ordinary responses, but they break down for outputs such as CSV when Restish is streaming items from pagination or NDJSON/SSE:
- the formatter may need to emit a header once, then many records
- the host should not have to buffer the entire stream just to satisfy a plugin
- one-shot formatter invocations per item create inconsistent behavior between collected and streamed output
The default hook-plugin contract is intentionally one-shot:
- Restish selects discovered plugins whose manifest declares a hook.
- It builds a hook-specific input message.
- It starts the plugin executable.
- It writes one CBOR data item to stdin.
- It reads one result from stdout.
- The plugin exits.
Most hooks use a bounded request/reply helper with these design constraints:
- one typed request message in
- one typed reply message out
- bounded wait with a configurable timeout
- plugin stderr surfaced on failure when helpful
- non-zero exit is an error
The current implementation uses a generic helper with a default timeout. The design now requires that timeout to be configurable by hook type and overridable where a workflow genuinely needs more time.
Each hook message type should have its own request and response schema. A single "decode everything into one broad struct" approach is easy initially but makes future protocol evolution fragile.
The design preference is:
- auth request/response types
- request middleware request/response types
- response middleware request/response types
- loader request/response types
- formatter session message types
That keeps unknown-field handling and future evolution much safer.
The auth hook runs during request preparation. Restish sends:
- API name
- profile name
- auth params from config
- the outbound request method, URI, and headers
The plugin replies with request updates, typically header changes. The reply is merged into the outbound request before it is sent.
This keeps external auth providers inside the same request-hook stage as built-in auth handlers.
When generated-operation auth selects multiple credential bindings for one
request, Restish applies every built-in credential first and then invokes auth
hook plugins once with the final prepared request. The hook input omits
per-credential params in that multi-credential case until the protocol grows
an explicit combined credential context; single-credential auth continues to
send the credential params with secrets redacted according to the plugin
manifest.
Provider-specific OAuth token exchange is a good fit for this hook/plugin boundary when the built-in OAuth flows are too small. The plugin can own the provider-specific token exchange and return ordinary request updates, while the core keeps common OAuth helpers focused on standard client credentials, authorization code, and device-code behavior.
The request-middleware hook receives the prepared request and can return
header updates before the request goes out. The implementation currently only
applies header changes, even if a plugin also returns method or URI fields.
That is an intentional practical boundary: middleware runs after Restish has already prepared the request object and transport options.
If a future middleware contract needs broader request mutation, that should be a new explicit hook capability rather than an undocumented side effect.
Request-signing plugins may require the final request body. Restish includes a
SHA-256 hash of replayable request bodies in hook request metadata. Plugins that
declare the request.final_body required feature, or plugins that opt into
auth-secret forwarding, may also receive the final body bytes. Non-replayable
bodies are omitted because the host must not consume the stream before sending
the request.
The response-middleware hook receives:
- the original request metadata
- the normalized response status, headers, and body
The plugin may:
- return
{"drop": true}to suppress output entirely - return
{"follow": {...}}to tell Restish to make a follow-up request - return
{"response": {...}}to replace body fields or merge headers
Response middleware runs on the interpreted response path: interactive output,
explicit filters, metadata shortcuts, collection, explicit output formats, and
explicit --rsh-print values. It does not run for the automatic raw-download
path (restish URL > file with no filter, collection, metadata shortcut, or
output format), because that path preserves the original response body bytes for
files and shell pipelines.
The response update's headers object is a partial update: keys returned by the
plugin replace those individual response header values, while omitted inbound
headers remain unchanged.
The follow-up path is especially important: the plugin asks Restish to issue
the request, so auth, retries, TLS, credential stripping, and other core
behaviors still apply. A follow request carries:
method, defaulting toGETuri- optional
headers - optional
body - optional
content_type, or aContent-Typeheader when a body is present
The host encodes the body through the content registry just like normal request bodies. Response middleware does not run again for the follow-up request, which prevents accidental loops.
Loader plugins let Restish recognize non-built-in spec formats. A loader plugin
declares loader_content_types in its manifest, which Restish turns into
registered spec.Loader instances at startup.
When a matching content type is detected, Restish sends the raw body to the plugin and expects back:
content_type: the detected source content type, when knownsource_urlorlocal_path: source metadata, when knownbody: an OpenAPI document as bytes or a string- optional response
content_type
The plugin does not return Restish's internal API model directly. Instead, it returns an OpenAPI document, and Restish parses that through the normal OpenAPI-loading path. That keeps generated commands aligned with the rest of the system.
This is an intentionally narrow trust boundary. Loader plugins transform one document format into the host's canonical API-description path; they do not directly define commands.
Formatter plugins declare formatter_names in the manifest. Each declared name
becomes available through -o <name>.
Formatter hooks are slightly different from the other hook types because stdout is treated as raw formatted bytes rather than a CBOR reply.
Formatter plugins receive a stream of formatter messages on stdin. The host
starts one plugin process, then sends:
formatterwithevent: "start"- zero or more
formattermessages withevent: "item" - one final
formattermessage withevent: "end"
The start message carries response metadata (proto, status, headers,
links) and may also include a full body for ordinary non-streaming
responses.
Each item message carries one body/sub-value to render. This is what lets the
same formatter handle:
- a normal one-shot body by reading
start.response.body - paginated output by rendering each
item - NDJSON/SSE output by rendering each
item
The plugin writes raw formatted bytes to stdout as values arrive and exits after
the end message or EOF on stdin.
This model is intentionally narrow:
- only formatter hooks get the long-lived stream session
- the host still owns pagination, filtering, and event parsing
- plugins do not get to drive the HTTP loop or ask for additional pages
The practical goal is to support stateful output modes such as CSV without forcing Restish to invent formatter-specific buffering behavior in the core.
Hook failures should be explicit.
If a hook:
- times out
- exits non-zero
- returns malformed data
- writes an invalid response
then Restish should surface a hook-specific error with plugin identity.
Whether the command continues depends on the hook category:
- auth and loader failures are usually fatal
- response middleware may choose to fail the request rather than emit inconsistent output
- formatter failures are fatal once output has started
Formatter hooks are renderers, not planners. The host still decides:
- document versus record semantics
- pagination behavior
- filter evaluation strategy
- stream parsing
This keeps plugins focused and prevents each formatter from needing to re-implement the core data-flow model.
That is simpler for the core binary, but it forces every custom formatter, loader, or auth hook to ship as a custom Restish build.
That would expose too much of Restish's internal representation. Requiring loader plugins to hand back OpenAPI keeps the seam smaller and more stable.
That keeps the protocol simpler, but it forces the host to choose between:
- buffering entire paginated or streaming result sets before formatting, or
- calling formatter plugins once per item and accepting inconsistent behavior
Neither is a good fit for stateful formats such as CSV.
JSON would be easier to inspect manually, but CBOR is more efficient for binary payloads, maps naturally to byte-oriented plugin messages, and is self-delimiting over a stream.
The current implementation is centered in:
internal/cli/hooks.gofor auth and middleware integrationinternal/spec/plugin_loader.gofor loader-backed spec loadinginternal/output/plugin_formatter.gofor formatter-backed outputinternal/cli/plugin_hook_test.gofor end-to-end examples of every hook type
One detail worth preserving is that hook plugins are called from well-defined seams in the existing pipeline instead of becoming alternate pipelines. Even with streaming formatter sessions, the goal is extension, not bypass.