Restish needs to support two different output experiences:
- a single logical response document
- an incremental stream of records
Those two experiences overlap in implementation, but they should not be
confused in the user interface. In particular, explicit document formats such
as json and yaml must always produce valid documents, while line-oriented
record processing should be explicit.
This design introduces a user-facing distinction between document output
and record output. Pagination, streaming, filtering, auto rendering,
--rsh-print, and formatter plugins should all compose through that
distinction.
Restish currently has several competing goals:
- automatic pagination should "just work" for list endpoints
- SSE and NDJSON responses should emit output immediately
- TTY users should get readable, colored, low-latency feedback
- shell users should be able to process one item at a time
-o jsonmust always produce valid JSON- filtered and paginated output should stay consistent with unpaginated output
- formatter plugins should be able to render paginated and streaming output without the host buffering everything unnecessarily
For stream-shaped responses, "immediately" means once the server or intermediary has delivered a complete event or record to the client. Restish should not add its own full-response buffering, but it cannot make an upstream-buffered response appear incremental.
The old pagination split was too coarse:
- non-collect mode streamed one item at a time
- collect mode buffered everything and re-entered the normal formatter path
That was simple, but it caused two kinds of surprises:
- Grammar surprises. An explicit format like
-o jsoncould become an invalid stream of adjacent JSON values instead of one JSON document. - Shape surprises. A paginated list endpoint could render as a wrapped
object, an array, or a stream of bare items depending on whether a
nextlink was present in that particular response.
The second problem matters because pagination is discovered dynamically. At request time Restish may not know whether an endpoint will paginate; it learns that only after inspecting the first response's hypermedia links or per-API pagination config.
The user experience therefore needs a stable rule that does not let the same endpoint silently change output semantics just because one invocation had a second page and another did not.
Restish should treat output formats as belonging to one of two families.
Document formats represent one logical result:
autojsonyaml- future document-oriented plugins
These formats must preserve their framing guarantees:
-o jsonalways emits one valid JSON document-o yamlalways emits one valid YAML document-o autoemits the default body/value presentation
Document formats may need to buffer paginated data or otherwise switch into a collect-like execution path to uphold that contract.
Record formats represent one item or event at a time:
ndjsonlinesfor scalar valuescsv- future record-oriented plugins
These formats optimize for first-byte latency and shell-friendliness. They are allowed to emit one result at a time because that is their explicit contract.
An explicit -o <format> must determine the framing contract up front.
That means:
-o jsonimplies document semantics-o yamlimplies document semantics-o autoimplies document semantics for bounded values and human presentation for TTY streams-o ndjsonimplies record semantics-o linesimplies scalar value-per-line semantics- plugin formats should declare whether they are document-oriented, record-oriented, or both
Pagination may change how Restish executes, but not what contract the user asked for.
When stdout is not a TTY and the user did not request a filter, metadata shortcut, pagination collection, or output format, Restish should write the response body bytes after HTTP content-encoding decompression. Redirection is therefore a byte-preserving save path for JSON, CBOR, YAML, images, text, and unknown payloads.
This keeps a clean product distinction:
- redirection saves the response body bytes
-o <format>transforms the decoded body or selected value- filters and metadata shortcuts render normalized values
--rsh-collectopts into a synthesized multi-page document
The rule is:
- when Restish is still writing the original unmodified payload, raw body-byte output is meaningful for redirected stdout
- when an explicit filter selects a scalar, Restish may print the scalar plainly because the selected value is already shell-native
- when Restish is writing a normalized, collected, or transformed structured
value without an explicit format, redirected output should default to pretty
JSON; explicit
--rsh-print=bselects compact rendered output for scripts
Practically, saving a CBOR response keeps CBOR bytes:
restish api.rest.sh/formats/cbor > response.cborConverting that same response to JSON is explicit:
restish api.rest.sh/formats/cbor -o json > response.json-o json should always emit one valid JSON document, but it should not change
what a filter means. Pagination mode determines the data model; output format
determines the serialization contract.
This keeps the JSON contract simple:
- paginated bounded responses can still become one valid JSON document
- filtered per-item results can be collected into a JSON array
- collection transforms remain explicit through
--rsh-collect
It also means -o json is never a streaming wire format. Users who want JSON
values incrementally should choose -o ndjson.
Restish should add a built-in ndjson formatter for record-oriented machine
consumption.
NDJSON is a good fit because:
- each output item is still valid JSON
- one item per line works naturally with shell pipelines
- it matches the existing incremental stream mental model
- it has an established de facto specification and media type
-o ndjson is the correct answer for:
- paginated item-by-item processing
- SSE / NDJSON stream processing
- shell loops,
jq -r,while read, andxargs
Restish should add a built-in lines formatter for shell-native scalar values.
It prints strings without JSON quotes and prints arrays or streams of scalars
one value per line.
-o lines is the correct answer when a filter selects values for shell tools
such as while read, xargs, or command substitution. It must reject objects
and arrays containing objects so line output does not silently erase structured
shape. Users who need structured data should choose -o json, -o ndjson, or
another structured format.
Auto output is a human/default body/value format, but it should still provide
low-latency feedback. HTTP status and header context is not formatter output;
the CLI print layer writes it when --rsh-print includes h or H.
For bounded non-paginated responses, auto output writes only the selected value to stdout:
- text as text
- images through terminal image presentation when supported
- structured values as pretty JSON on a terminal
- binary bodies as a short notice on a terminal
When the selected value is response metadata or the full response envelope,
auto and table-like renderers should display that metadata instead of
assuming every useful field lives under body. Status, headers, links, and
body are all legitimate output roots.
For pagination and true streams, auto output may switch to a record-oriented presentation mode internally while preserving a coherent human interface:
- write the response status and headers once when
--rsh-printincludesh - render each item or event as its own pretty-printed JSON block
- separate records clearly
- keep ANSI highlighting when color is enabled
This gives TTY users feedback as quickly as possible without pretending the result is one JSON document.
Filters fall into two broad categories:
- per-record filters such as
body.idorbody.{format,self} - whole-collection filters such as
length,.body | map(.id),sort_by(...), orgroup_by(...)
Per-record filters can compose with record formats and paginated incremental
execution. Each record is evaluated through a mini normalized response where the
current item or stream event is under body; this keeps body.* filters
consistent with full-response filtering.
Whole-collection filters require a full logical collection, even when the
response arrived in pages. Restish should not try to infer this from the filter
syntax because shorthand and jq both contain expressions that are hard to
classify safely and predictably. Users ask for whole-collection semantics with
--rsh-collect. This flag is the canonical user-facing name for the mode in
v2; Restish should improve help, hints, and examples around it rather than add
a second spelling that makes filter scope harder to learn.
Restish therefore needs an output planning step that classifies:
- whether the underlying result is bounded or unbounded
- whether the selected format is document or record oriented
- whether
--rsh-collectrequests whole-collection evaluation - whether pagination needs to preserve a wrapper object via
items_path
Without --rsh-collect, paginated filters run per item against that mini
response wrapper. Document formats then render the filtered item results as one
valid document; record formats can emit them as records.
For paginated bounded responses, Restish should preserve the logical response shape whenever possible:
- a bare paginated array stays an array in document formats
- a wrapped object with
items_path: datashould stay an object whosedatafield becomes the merged collection - paginated filtering should operate on the logical merged shape only when
--rsh-collectis set
This avoids the surprise where the same endpoint produces an object sometimes
and a stream of bare items other times depending only on whether a next link
was present.
Open-ended SSE and NDJSON responses differ from paginated collections in one important way: there may never be a natural end-of-document.
For those responses:
- record formats are the natural fit
- auto output should render records incrementally in interactive terminals
- document formats like
jsonshould be treated as bounded-document requests
In practice, that means -o json on an unbounded stream should either:
- buffer until EOF and only succeed for bounded streams, or
- fail with a clear error telling the user to use
-o ndjson
The latter is the better UX for clearly live feeds.
Formatter plugins should declare whether they support:
- full document rendering
- record rendering
- both
The existing formatter session protocol already supports streamed item
messages, which makes it a natural fit for record-oriented plugins such as CSV.
Document-oriented plugins should continue to receive a full logical body for ordinary responses and collected paginated document output.
The host should keep owning:
- pagination
- filtering
- normalized response planning
- SSE / NDJSON parsing
Plugins should stay renderers, not alternate execution pipelines.
The output planner should make these decisions in order:
- classify the underlying response as:
- bounded non-paginated
- bounded paginated
- unbounded stream
- resolve the selected format family:
- explicit document format
- explicit record format
- adaptive default based on TTY and whether output is still raw
- decide filter scope:
- none
- per-record pagination filter
- whole-collection filter requested by
--rsh-collect
- decide whether the logical result must preserve a wrapper object via
pagination config such as
items_path - choose execution strategy:
- direct bounded document render
- collected paginated document render
- incremental record render
- auto incremental human mode
- fail because the requested format cannot be satisfied safely
This planning step should happen before bytes are written to stdout. Once output starts, Restish should not silently discover it chose the wrong framing mode.
Use when:
- the response is bounded
- no pagination collection merge is needed
- the selected format is document-oriented
Use when:
- the response is paginated and bounded
--rsh-collectrequested whole-collection semantics, or- no per-record filter transform is active and the selected output contract is a document format
Use when:
- the selected format is record-oriented
- the filter can run per record
- the response is either paginated or streamed
Use when:
- the selected format is
auto - low latency is valuable
- the result is paginated or streamed
This is still a document-oriented human contract, but it is implemented with a record-oriented internal execution strategy.
The planner should fail clearly when the user asks for a contract Restish cannot satisfy safely.
Examples:
-o jsonon a clearly unbounded live stream- a whole-collection jq transform combined with forced incremental-only output
- a formatter plugin that supports only record rendering when the selected mode requires a full document
Clear failure is better than silently emitting invalid or misleading output.
- default TTY: auto, low-latency human output with response status and headers on stdout
- default redirected non-TTY without filters or formats: first response body bytes, with automatic pagination skipped
-o json: valid pretty JSON document by default; unfiltered pagination merges items, filtered pagination renders filtered item results-o yaml: valid YAML document; unfiltered pagination merges items, filtered pagination renders filtered item results-o auto: render progressively for humans-o ndjson: stream one item per line-o lines: stream scalar values one per line, error on structured values-o csv: stream one record per row if the formatter supports it
- default TTY: auto incremental event output
- default non-TTY: NDJSON-style item output when the payload is structured
-o ndjson: one JSON value per line-o auto: incremental human-readable event view-o json: not a streaming mode; should require a bounded stream or error
- per-record filter + record format: run incrementally
- per-record filter + document format: filter each item, then render those values as a valid document
- whole-collection filter + paginated response: use
--rsh-collect --rsh-collect: force whole-collection evaluation
Human-in-the-loop TTY scan of a paginated endpoint:
restish api.rest.sh/images -f 'body.{format,self}'This should render incrementally in auto mode, one record at a time, so the user sees feedback immediately.
Shell pipeline processing paginated items:
restish api.rest.sh/images -f 'body.id' -o lines | while read id; do
echo "process $id"
doneDump all IDs to a JSON file:
restish api.rest.sh/images --rsh-collect -f '.body | map(.id)' -o json > ids.jsonBecause the filter is whole-collection, the command opts into collection
explicitly. -o json makes the output serialization explicit for scripts.
Export paginated data to CSV:
restish api.rest.sh/images -o csv -f 'body.{id,format,self}'Follow a live event stream with less noise:
restish api.example.com/events -f 'body.{type,id,message}'This should print events as they arrive in auto mode.
This preserves a smaller implementation surface, but it leads directly to the bugs and surprises seen in paginated JSON output. Users need stable guarantees from explicit formats.
This makes latency look good, but it violates the plain meaning of json.
Adjacent JSON objects are not a valid JSON document.
This works only for bounded record streams and still needs special handling for
wrapper objects, collection-wide filters, and truly unbounded event streams.
It is not a good universal contract for -o json.
Go's experimental encoding/json/v2 and encoding/json/jsontext packages are
promising for lower-level JSON writing, but they remain experimental and gated
behind GOEXPERIMENT=jsonv2. They may be useful as an internal implementation
detail, but they should not define the user-facing output model.
This design intentionally separates framing semantics from execution strategy and filter scope:
- framing semantics come from the selected output mode
- filter scope comes from pagination mode: item-by-item by default,
whole-collection with
--rsh-collect - execution strategy is chosen by Restish to satisfy that contract without changing filter meaning
That distinction keeps pagination, filtering, auto rendering, and plugins composable without requiring the user to reason about internal control flow.