[object Object]

← back to Cli Printing Press

feat(cli): emit fetchFull<X> companion for GET-embedded paged sub-resources (#1301)

900b335ce15efab58b8007a8cdb80de2de80ff4e · 2026-05-13 14:31:32 -0700 · Trevin Chow

* feat(cli): emit fetchFull<X> companion for GET-embedded paged sub-resources

Detect properties in a GET endpoint's success response whose nested shape
is a paged envelope (items+next), and emit a per-endpoint
fetchFull<Resource><Endpoint><Property> helper that calls the dedicated
child endpoint and paginates until exhausted. Backed by a single shared
runtime helper (fetchEmbeddedPagedSubresource) gated on a HelperFlag so
unused CLIs don't carry it.

The standard GET handler keeps returning the parent's truncated embed for
back-compat; the helper is opt-in for hand-written novel commands that
need the complete child collection. This raises the floor for every CLI
generated from specs that embed paged sub-resources (Spotify playlists,
Slack conversations, Atlassian issues, Notion pages, GitHub pulls, etc.)
without changing existing generated output.

Closes #1277

* fix(cli): distinguish URL-bearing next fields from opaque cursors

Greptile review caught a real defect in the runtime helper: the
detector classified every string-typed next-page field uniformly
(next, next_url, next_cursor, next_page_token, cursor), and the
helper assigned the field's value to nextURL on every iteration.
For URL-bearing fields (next, next_url) the value is a fully-qualified
URL the runtime can GET directly; for opaque cursors (next_cursor,
next_page_token, cursor) the value is a token that must be forwarded
as a specific query parameter on the next request. The embedded
envelope never declares which parameter that is, so the runtime would
GET against the cursor string itself and resolve it to a nonsense
path under the client BaseURL.

Splits nextFieldStringNames into nextFieldURLNames (followable) and
nextFieldCursorNames (unfollowable), adds an EmbeddedPagedSubresource.
NextIsURL flag, and updates the runtime helper to single-fetch + emit
a truncation event for non-URL signals — identical to the has_more
branch. Generated wrappers pass both NextIsURL and NextIsBoolean so
the helper picks the right path per envelope shape.

Also addresses two adjacent review findings:
- Replaces the residual "Spotify" vendor name in spec.go with a
  generic placeholder (AGENTS.md "no hardcoded API names in
  reusable artifacts").
- Adds a generate-embedded-paged-api golden case exercising all three
  envelope flavors (URL, cursor, has_more) so the new generation
  path is locked in for future refactors.

Refs #1277

* test(cli): refresh generate-embedded-paged-api goldens against current main

Three fixture files needed updating after rebase onto main:
- customers_get.go
- pages_get.go
- playlists_get.go

The diff is purely cosmetic: main now wraps the provenance-print block
in `if wantsHumanTable(...)` (from #1280), so the actual generator
output gains that guard. #1301's fixture predates the change.

Behavior of this PR's fetchFull<X> companion emission is unchanged.

Files touched

Diff

commit 900b335ce15efab58b8007a8cdb80de2de80ff4e
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Wed May 13 14:31:32 2026 -0700

    feat(cli): emit fetchFull<X> companion for GET-embedded paged sub-resources (#1301)
    
    * feat(cli): emit fetchFull<X> companion for GET-embedded paged sub-resources
    
    Detect properties in a GET endpoint's success response whose nested shape
    is a paged envelope (items+next), and emit a per-endpoint
    fetchFull<Resource><Endpoint><Property> helper that calls the dedicated
    child endpoint and paginates until exhausted. Backed by a single shared
    runtime helper (fetchEmbeddedPagedSubresource) gated on a HelperFlag so
    unused CLIs don't carry it.
    
    The standard GET handler keeps returning the parent's truncated embed for
    back-compat; the helper is opt-in for hand-written novel commands that
    need the complete child collection. This raises the floor for every CLI
    generated from specs that embed paged sub-resources (Spotify playlists,
    Slack conversations, Atlassian issues, Notion pages, GitHub pulls, etc.)
    without changing existing generated output.
    
    Closes #1277
    
    * fix(cli): distinguish URL-bearing next fields from opaque cursors
    
    Greptile review caught a real defect in the runtime helper: the
    detector classified every string-typed next-page field uniformly
    (next, next_url, next_cursor, next_page_token, cursor), and the
    helper assigned the field's value to nextURL on every iteration.
    For URL-bearing fields (next, next_url) the value is a fully-qualified
    URL the runtime can GET directly; for opaque cursors (next_cursor,
    next_page_token, cursor) the value is a token that must be forwarded
    as a specific query parameter on the next request. The embedded
    envelope never declares which parameter that is, so the runtime would
    GET against the cursor string itself and resolve it to a nonsense
    path under the client BaseURL.
    
    Splits nextFieldStringNames into nextFieldURLNames (followable) and
    nextFieldCursorNames (unfollowable), adds an EmbeddedPagedSubresource.
    NextIsURL flag, and updates the runtime helper to single-fetch + emit
    a truncation event for non-URL signals — identical to the has_more
    branch. Generated wrappers pass both NextIsURL and NextIsBoolean so
    the helper picks the right path per envelope shape.
    
    Also addresses two adjacent review findings:
    - Replaces the residual "Spotify" vendor name in spec.go with a
      generic placeholder (AGENTS.md "no hardcoded API names in
      reusable artifacts").
    - Adds a generate-embedded-paged-api golden case exercising all three
      envelope flavors (URL, cursor, has_more) so the new generation
      path is locked in for future refactors.
    
    Refs #1277
    
    * test(cli): refresh generate-embedded-paged-api goldens against current main
    
    Three fixture files needed updating after rebase onto main:
    - customers_get.go
    - pages_get.go
    - playlists_get.go
    
    The diff is purely cosmetic: main now wraps the provenance-print block
    in `if wantsHumanTable(...)` (from #1280), so the actual generator
    output gains that guard. #1301's fixture predates the change.
    
    Behavior of this PR's fetchFull<X> companion emission is unchanged.
---
 internal/generator/embedded_paged_test.go          |  197 +++
 internal/generator/generator.go                    |    7 +
 .../generator/templates/command_endpoint.go.tmpl   |   25 +
 .../generator/templates/command_promoted.go.tmpl   |   25 +
 internal/generator/templates/helpers.go.tmpl       |  136 ++
 internal/openapi/embedded_paged_test.go            |  437 ++++++
 internal/openapi/parser.go                         |  180 +++
 internal/spec/spec.go                              |   47 +-
 .../generate-embedded-paged-api/artifacts.txt      |    4 +
 .../cases/generate-embedded-paged-api/command.txt  |    2 +
 .../internal/cli/customers_get.go                  |  100 ++
 .../embedded-paged-api/internal/cli/helpers.go     | 1659 ++++++++++++++++++++
 .../embedded-paged-api/internal/cli/pages_get.go   |  100 ++
 .../internal/cli/playlists_get.go                  |  100 ++
 .../expected/generate-embedded-paged-api/exit.txt  |    1 +
 .../generate-embedded-paged-api/stderr.txt         |    2 +
 .../generate-embedded-paged-api/stdout.txt         |    0
 testdata/golden/fixtures/embedded-paged-api.yaml   |  137 ++
 18 files changed, 3148 insertions(+), 11 deletions(-)

diff --git a/internal/generator/embedded_paged_test.go b/internal/generator/embedded_paged_test.go
new file mode 100644
index 00000000..c7a26e89
--- /dev/null
+++ b/internal/generator/embedded_paged_test.go
@@ -0,0 +1,197 @@
+package generator
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestEmbeddedPagedHelperEmitted_NonPromoted ensures the generator emits
+// fetchFull<Endpoint><Property> companion helpers for the non-promoted
+// command-emission path. The multi-endpoint resource here keeps the GET
+// from being promoted, so command_endpoint.go.tmpl is the active path.
+func TestEmbeddedPagedHelperEmitted_NonPromoted(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("embedded-paged-nonpromoted")
+	apiSpec.Resources = map[string]spec.Resource{
+		"playlists": {
+			Description: "Manage playlists",
+			Endpoints: map[string]spec.Endpoint{
+				"list": {Method: "GET", Path: "/playlists", Description: "List playlists"},
+				"get": {
+					Method:      "GET",
+					Path:        "/playlists/{id}",
+					Description: "Get one playlist",
+					Params: []spec.Param{
+						{Name: "id", Type: "string", Required: true, Positional: true, PathParam: true},
+					},
+					EmbeddedPagedSubresources: []spec.EmbeddedPagedSubresource{
+						{
+							Property:   "tracks",
+							ChildPath:  "/playlists/{id}/tracks",
+							ItemsField: "items",
+							NextField:  "next",
+						},
+					},
+				},
+			},
+		},
+	}
+
+	outDir := filepath.Join(t.TempDir(), "embedded-paged-nonpromoted-pp-cli")
+	require.NoError(t, New(apiSpec, outDir).Generate())
+
+	getSrc, err := os.ReadFile(filepath.Join(outDir, "internal", "cli", "playlists_get.go"))
+	require.NoError(t, err)
+	body := string(getSrc)
+	assert.Contains(t, body, "func fetchFullPlaylistsGetTracks(",
+		"non-promoted GET should emit a fetchFull<Resource><Endpoint><Property> helper")
+	assert.Contains(t, body, "childPath := \"/playlists/{id}/tracks\"",
+		"helper should hard-code the conventional child path so callers pass only path-param values")
+	assert.Contains(t, body, "fetchEmbeddedPagedSubresource(c, childPath,",
+		"per-endpoint helper should delegate to the shared runtime helper rather than open-coding the page loop")
+
+	helpersSrc, err := os.ReadFile(filepath.Join(outDir, "internal", "cli", "helpers.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(helpersSrc), "func fetchEmbeddedPagedSubresource(",
+		"helpers.go must emit the shared paginator when at least one endpoint has detected sub-resources")
+	assert.Contains(t, string(helpersSrc), "embeddedPagedSubresourcePageCap",
+		"shared helper must carry the page-count cap so callers can't spin against a misbehaving server")
+
+	runGoCommand(t, outDir, "build", "./internal/cli")
+}
+
+// TestEmbeddedPagedHelperEmitted_Promoted covers the single-endpoint
+// resource case where the generator promotes the GET to a top-level
+// command (command_promoted.go.tmpl is the active path). The helper
+// must appear there too — otherwise the bug only fixes itself for
+// CLIs whose specs happen to declare a sibling list endpoint.
+func TestEmbeddedPagedHelperEmitted_Promoted(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("embedded-paged-promoted")
+	apiSpec.Resources = map[string]spec.Resource{
+		"playlists": {
+			Description: "Manage playlists",
+			Endpoints: map[string]spec.Endpoint{
+				"get": {
+					Method:      "GET",
+					Path:        "/playlists/{id}",
+					Description: "Get one playlist",
+					Params: []spec.Param{
+						{Name: "id", Type: "string", Required: true, Positional: true, PathParam: true},
+					},
+					EmbeddedPagedSubresources: []spec.EmbeddedPagedSubresource{
+						{
+							Property:   "tracks",
+							ChildPath:  "/playlists/{id}/tracks",
+							ItemsField: "items",
+							NextField:  "next",
+						},
+					},
+				},
+			},
+		},
+	}
+
+	outDir := filepath.Join(t.TempDir(), "embedded-paged-promoted-pp-cli")
+	require.NoError(t, New(apiSpec, outDir).Generate())
+
+	promotedSrc, err := os.ReadFile(filepath.Join(outDir, "internal", "cli", "promoted_playlists.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(promotedSrc), "func fetchFullPlaylistsGetTracks(",
+		"promoted-command file should carry the same helper as the non-promoted file")
+
+	runGoCommand(t, outDir, "build", "./internal/cli")
+}
+
+// TestEmbeddedPagedHelperEmitted_HasMore exercises the boolean has_more
+// branch — the emitted helper must declare it stops after page 1 rather
+// than silently falling through and looping forever against a header-
+// driven cursor it cannot synthesize.
+func TestEmbeddedPagedHelperEmitted_HasMore(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("embedded-paged-hasmore")
+	apiSpec.Resources = map[string]spec.Resource{
+		"customers": {
+			Description: "Customers",
+			Endpoints: map[string]spec.Endpoint{
+				"list": {Method: "GET", Path: "/customers", Description: "List customers"},
+				"get": {
+					Method:      "GET",
+					Path:        "/customers/{id}",
+					Description: "Get customer",
+					Params: []spec.Param{
+						{Name: "id", Type: "string", Required: true, Positional: true, PathParam: true},
+					},
+					EmbeddedPagedSubresources: []spec.EmbeddedPagedSubresource{
+						{
+							Property:      "subscriptions",
+							ChildPath:     "/customers/{id}/subscriptions",
+							ItemsField:    "data",
+							NextField:     "has_more",
+							NextIsBoolean: true,
+						},
+					},
+				},
+			},
+		},
+	}
+
+	outDir := filepath.Join(t.TempDir(), "embedded-paged-hasmore-pp-cli")
+	require.NoError(t, New(apiSpec, outDir).Generate())
+
+	getSrc, err := os.ReadFile(filepath.Join(outDir, "internal", "cli", "customers_get.go"))
+	require.NoError(t, err)
+	body := string(getSrc)
+	assert.Contains(t, body, "func fetchFullCustomersGetSubscriptions(")
+	assert.Contains(t, body, `fetchEmbeddedPagedSubresource(c, childPath, "has_more", false, true)`,
+		"has_more-style envelopes should delegate to the shared paginator with nextIsURL=false, nextIsBoolean=true")
+
+	runGoCommand(t, outDir, "build", "./internal/cli")
+}
+
+// TestEmbeddedPagedHelperNotEmittedWhenEmpty confirms the helper block
+// is gated correctly: endpoints without detected sub-resources must not
+// emit any fetchFull function (avoids template noise and stray
+// references to undefined identifiers).
+func TestEmbeddedPagedHelperNotEmittedWhenEmpty(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("embedded-paged-empty")
+	apiSpec.Resources = map[string]spec.Resource{
+		"items": {
+			Description: "Items",
+			Endpoints: map[string]spec.Endpoint{
+				"list": {Method: "GET", Path: "/items", Description: "List items"},
+				"get": {
+					Method:      "GET",
+					Path:        "/items/{id}",
+					Description: "Get item",
+					Params: []spec.Param{
+						{Name: "id", Type: "string", Required: true, Positional: true, PathParam: true},
+					},
+				},
+			},
+		},
+	}
+
+	outDir := filepath.Join(t.TempDir(), "embedded-paged-empty-pp-cli")
+	require.NoError(t, New(apiSpec, outDir).Generate())
+
+	getSrc, err := os.ReadFile(filepath.Join(outDir, "internal", "cli", "items_get.go"))
+	require.NoError(t, err)
+	assert.NotContains(t, string(getSrc), "fetchFull",
+		"endpoints with no detected embedded paged sub-resources must not emit a helper")
+
+	helpersSrc, err := os.ReadFile(filepath.Join(outDir, "internal", "cli", "helpers.go"))
+	require.NoError(t, err)
+	assert.NotContains(t, string(helpersSrc), "fetchEmbeddedPagedSubresource",
+		"shared paginator must be gated on HasEmbeddedPaged so unused CLIs don't carry dead code")
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index d5d2c4ec..ddc1ebf4 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -578,6 +578,7 @@ type HelperFlags struct {
 	HasMultiPositional bool // spec has endpoints with 2+ positional params → emit usageErr
 	HasDataLayer       bool // CLI has a local store (sync/search) → emit provenance helpers
 	HasClientLimit     bool // at least one endpoint needs client-side limit truncation → emit truncateJSONArray
+	HasEmbeddedPaged   bool // at least one GET endpoint has detected embedded paged sub-resources → emit fetchEmbeddedPagedSubresource
 }
 
 // computeHelperFlags scans the spec's resources to determine which helpers are needed.
@@ -591,6 +592,9 @@ func computeHelperFlags(s *spec.APISpec) HelperFlags {
 			if endpointNeedsClientLimit(e) {
 				flags.HasClientLimit = true
 			}
+			if len(e.EmbeddedPagedSubresources) > 0 {
+				flags.HasEmbeddedPaged = true
+			}
 			positionalCount := 0
 			for _, p := range e.Params {
 				if p.Positional || p.PathParam {
@@ -612,6 +616,9 @@ func computeHelperFlags(s *spec.APISpec) HelperFlags {
 				if endpointNeedsClientLimit(e) {
 					flags.HasClientLimit = true
 				}
+				if len(e.EmbeddedPagedSubresources) > 0 {
+					flags.HasEmbeddedPaged = true
+				}
 				positionalCount := 0
 				for _, p := range e.Params {
 					if p.Positional || p.PathParam {
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index a81a253b..fa671a9f 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -784,3 +784,28 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 
 	return cmd
 }
+{{if and (eq .Endpoint.Method "GET") .Endpoint.EmbeddedPagedSubresources}}
+{{- $endpointCamel := camel .EndpointName}}
+{{- $funcPrefix := camel .FuncPrefix}}
+{{- $parentPath := .Endpoint.Path}}
+{{- range .Endpoint.EmbeddedPagedSubresources}}
+{{- $propCamel := camel .Property}}
+
+// fetchFull{{$funcPrefix}}{{$endpointCamel}}{{$propCamel}} returns every item
+// in the "{{.Property}}" sub-resource of GET {{$parentPath}}. The parent
+// response embeds at most the first page of this sub-resource, so any
+// caller that treats the embed as complete silently truncates; this
+// companion calls the dedicated child endpoint and paginates until
+// exhausted. pathParams must include every {placeholder} in the parent
+// path (e.g. {"id": "<value>"}).
+func fetchFull{{$funcPrefix}}{{$endpointCamel}}{{$propCamel}}(c interface {
+	GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, pathParams map[string]string) ([]json.RawMessage, error) {
+	childPath := "{{.ChildPath}}"
+	for name, val := range pathParams {
+		childPath = replacePathParam(childPath, name, val)
+	}
+	return fetchEmbeddedPagedSubresource(c, childPath, "{{.NextField}}", {{if .NextIsURL}}true{{else}}false{{end}}, {{if .NextIsBoolean}}true{{else}}false{{end}})
+}
+{{- end}}
+{{- end}}
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index f0664903..82e432d6 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -334,3 +334,28 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 
 	return cmd
 }
+{{if and (eq .Endpoint.Method "GET") .Endpoint.EmbeddedPagedSubresources}}
+{{- $endpointCamel := camel .EndpointName}}
+{{- $funcPrefix := camel .FuncPrefix}}
+{{- $parentPath := .Endpoint.Path}}
+{{- range .Endpoint.EmbeddedPagedSubresources}}
+{{- $propCamel := camel .Property}}
+
+// fetchFull{{$funcPrefix}}{{$endpointCamel}}{{$propCamel}} returns every item
+// in the "{{.Property}}" sub-resource of GET {{$parentPath}}. The parent
+// response embeds at most the first page of this sub-resource, so any
+// caller that treats the embed as complete silently truncates; this
+// companion calls the dedicated child endpoint and paginates until
+// exhausted. pathParams must include every {placeholder} in the parent
+// path (e.g. {"id": "<value>"}).
+func fetchFull{{$funcPrefix}}{{$endpointCamel}}{{$propCamel}}(c interface {
+	GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, pathParams map[string]string) ([]json.RawMessage, error) {
+	childPath := "{{.ChildPath}}"
+	for name, val := range pathParams {
+		childPath = replacePathParam(childPath, name, val)
+	}
+	return fetchEmbeddedPagedSubresource(c, childPath, "{{.NextField}}", {{if .NextIsURL}}true{{else}}false{{end}}, {{if .NextIsBoolean}}true{{else}}false{{end}})
+}
+{{- end}}
+{{- end}}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index caa99397..fcc1e2bd 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -12,6 +12,9 @@ import (
 	"errors"
 	"fmt"
 	"io"
+{{- if .HasEmbeddedPaged}}
+	"net/url"
+{{- end}}
 	"os"
 {{- if .HasDataLayer}}
 	"path/filepath"
@@ -744,6 +747,139 @@ func extractPaginatedItems(obj map[string]json.RawMessage) ([]json.RawMessage, b
 	return nil, false
 }
 
+{{- if .HasEmbeddedPaged}}
+
+// embeddedPagedSubresourcePageCap bounds the page loop in
+// fetchEmbeddedPagedSubresource so a misbehaving server returning a
+// non-empty next URL on every response can't spin forever. 500 covers
+// the largest realistic embedded sub-resource (e.g. 50k items at 100
+// per page); pathological servers trip the cap and surface an error
+// instead of hanging.
+const embeddedPagedSubresourcePageCap = 500
+
+// fetchEmbeddedPagedSubresource paginates the embedded paged
+// sub-resource at childPath until exhausted, returning the
+// concatenated items. The generator emits per-endpoint thin wrappers
+// that call this with the detected envelope shape; consolidating the
+// loop here keeps the page cap, error handling, and envelope-walking
+// logic in one place.
+//
+// nextField names the envelope's "more pages" property. nextIsURL
+// distinguishes the two cursor shapes:
+//
+//   - nextIsURL=true: the property carries a full URL the runtime can
+//     GET directly to fetch the next page (envelope shape: items+next).
+//   - nextIsURL=false: the property either carries an opaque cursor
+//     token whose required query-param name isn't part of the embedded
+//     envelope, or it's a has_more-style bool. In either case the
+//     helper performs a single GET and emits a stderr truncation event
+//     when the field indicates more pages exist — paginating further
+//     requires API-specific arithmetic that cannot be synthesized
+//     from the envelope alone, so callers must use the dedicated list
+//     endpoint for full traversal.
+//
+// Envelope lookups are case-insensitive: API authors sometimes
+// describe envelopes in one casing in the schema and serve another on
+// the wire (`NextPageToken` vs `nextPageToken`).
+func fetchEmbeddedPagedSubresource(c interface {
+	GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, childPath, nextField string, nextIsURL, nextIsBoolean bool) ([]json.RawMessage, error) {
+	all := make([]json.RawMessage, 0)
+	nextURL := ""
+	for page := 0; page < embeddedPagedSubresourcePageCap; page++ {
+		target := childPath
+		if nextURL != "" {
+			target = relativizeNextURL(nextURL)
+		}
+		data, err := c.GetWithHeaders(target, nil, nil)
+		if err != nil {
+			return nil, fmt.Errorf("paginating embedded sub-resource %q page %d: %w", childPath, page+1, err)
+		}
+		var obj map[string]json.RawMessage
+		if err := json.Unmarshal(data, &obj); err != nil {
+			return nil, fmt.Errorf("paginating embedded sub-resource %q page %d: unexpected response shape: %w", childPath, page+1, err)
+		}
+		if items, ok := extractPaginatedItems(obj); ok {
+			all = append(all, items...)
+		}
+		if !nextIsURL {
+			if moreRaw, ok := caseInsensitiveLookup(obj, nextField); ok {
+				if hasUnfollowableNextSignal(moreRaw, nextIsBoolean) {
+					emitEmbeddedPagedTruncationWarning(childPath)
+				}
+			}
+			return all, nil
+		}
+		nextRaw, ok := caseInsensitiveLookup(obj, nextField)
+		if !ok {
+			return all, nil
+		}
+		var nextStr string
+		if json.Unmarshal(nextRaw, &nextStr) != nil || nextStr == "" {
+			return all, nil
+		}
+		nextURL = nextStr
+	}
+	return all, fmt.Errorf("paginating embedded sub-resource %q: hit %d-page cap (likely a server returning a non-empty next URL on every response)", childPath, embeddedPagedSubresourcePageCap)
+}
+
+// hasUnfollowableNextSignal reports whether a non-URL next field
+// value indicates more pages exist (true bool, non-empty cursor
+// string) — the caller emits a truncation warning when this returns
+// true. Returns false on missing/null/false/empty signals.
+func hasUnfollowableNextSignal(raw json.RawMessage, nextIsBoolean bool) bool {
+	if nextIsBoolean {
+		var more bool
+		return json.Unmarshal(raw, &more) == nil && more
+	}
+	var cursor string
+	return json.Unmarshal(raw, &cursor) == nil && cursor != ""
+}
+
+func emitEmbeddedPagedTruncationWarning(childPath string) {
+	if humanFriendly {
+		fmt.Fprintf(os.Stderr, "warning: %s truncated; more pages available via the dedicated list endpoint.\n", childPath)
+	} else {
+		fmt.Fprintf(os.Stderr, `{"event":"truncated","sub_resource":%q}`+"\n", childPath)
+	}
+}
+
+// relativizeNextURL strips an absolute scheme+host from a next-page URL
+// so the client's BaseURL+path concat doesn't produce a double-prefixed
+// target. Vendors that return absolute next URLs (e.g. for offset-based
+// pagination) would otherwise concatenate against c.BaseURL on every
+// follow-up request and 404 from page 2 onward.
+func relativizeNextURL(raw string) string {
+	u, err := url.Parse(raw)
+	if err != nil || !u.IsAbs() {
+		return raw
+	}
+	path := u.Path
+	if path == "" {
+		path = "/"
+	}
+	if u.RawQuery != "" {
+		path += "?" + u.RawQuery
+	}
+	return path
+}
+
+// caseInsensitiveLookup returns obj[key] tolerating envelope casing
+// drift between the spec and the wire response.
+func caseInsensitiveLookup(obj map[string]json.RawMessage, key string) (json.RawMessage, bool) {
+	if raw, ok := obj[key]; ok {
+		return raw, true
+	}
+	lowered := strings.ToLower(key)
+	for k, raw := range obj {
+		if strings.ToLower(k) == lowered {
+			return raw, true
+		}
+	}
+	return nil, false
+}
+{{- end}}
+
 func rawAtPath(obj map[string]json.RawMessage, path string) (json.RawMessage, bool) {
 	if raw, ok := obj[path]; ok {
 		return raw, true
diff --git a/internal/openapi/embedded_paged_test.go b/internal/openapi/embedded_paged_test.go
new file mode 100644
index 00000000..cec02dc2
--- /dev/null
+++ b/internal/openapi/embedded_paged_test.go
@@ -0,0 +1,437 @@
+package openapi
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestEmbeddedPagedSubresources_Spotify exercises the Spotify-shape:
+// GET /playlists/{id} returns a struct whose `tracks` property is a
+// PagingObject (items + next). The detector must surface tracks as a
+// companion-helper candidate.
+func TestEmbeddedPagedSubresources_Spotify(t *testing.T) {
+	t.Parallel()
+
+	doc := []byte(`
+openapi: "3.0.0"
+info:
+  title: Spotify Mock
+  version: "1.0"
+servers:
+  - url: https://api.spotify.example/v1
+paths:
+  /playlists/{id}:
+    get:
+      operationId: getPlaylist
+      parameters:
+        - { name: id, in: path, required: true, schema: { type: string } }
+      responses:
+        "200":
+          description: ok
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  id: { type: string }
+                  name: { type: string }
+                  tracks:
+                    type: object
+                    properties:
+                      items:
+                        type: array
+                        items: { type: object }
+                      next: { type: string }
+                      total: { type: integer }
+`)
+	parsed, err := Parse(doc)
+	require.NoError(t, err)
+
+	var ep, ok = findGetEndpoint(parsed, "/playlists/{id}")
+	require.True(t, ok, "expected GET /playlists/{id} endpoint")
+	require.Len(t, ep.EmbeddedPagedSubresources, 1, "expected one detected sub-resource")
+	got := ep.EmbeddedPagedSubresources[0]
+	assert.Equal(t, "tracks", got.Property)
+	assert.Equal(t, "/playlists/{id}/tracks", got.ChildPath)
+	assert.Equal(t, "items", got.ItemsField)
+	assert.Equal(t, "next", got.NextField)
+	assert.True(t, got.NextIsURL, "`next` is the canonical URL-bearing next-page field")
+	assert.False(t, got.NextIsBoolean)
+}
+
+// TestEmbeddedPagedSubresources_HasMore covers the boolean has_more shape
+// used by Stripe-style envelopes nested under a parent GET.
+func TestEmbeddedPagedSubresources_HasMore(t *testing.T) {
+	t.Parallel()
+
+	doc := []byte(`
+openapi: "3.0.0"
+info: { title: Stripe Mock, version: "1.0" }
+servers: [{ url: https://api.stripe.example/v1 }]
+paths:
+  /customers/{id}:
+    get:
+      operationId: getCustomer
+      parameters:
+        - { name: id, in: path, required: true, schema: { type: string } }
+      responses:
+        "200":
+          description: ok
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  id: { type: string }
+                  subscriptions:
+                    type: object
+                    properties:
+                      data:
+                        type: array
+                        items: { type: object }
+                      has_more: { type: boolean }
+`)
+	parsed, err := Parse(doc)
+	require.NoError(t, err)
+	ep, ok := findGetEndpoint(parsed, "/customers/{id}")
+	require.True(t, ok)
+	require.Len(t, ep.EmbeddedPagedSubresources, 1)
+	got := ep.EmbeddedPagedSubresources[0]
+	assert.Equal(t, "subscriptions", got.Property)
+	assert.Equal(t, "data", got.ItemsField)
+	assert.Equal(t, "has_more", got.NextField)
+	assert.True(t, got.NextIsBoolean)
+	assert.False(t, got.NextIsURL, "has_more envelopes carry no URL — runtime must single-fetch and warn")
+}
+
+// TestEmbeddedPagedSubresources_NoEnvelope ensures plain non-paged nested
+// objects don't trigger detection — a top-level scalar field, an array
+// without a next-page sibling, and an object missing the next signal must
+// all be skipped.
+func TestEmbeddedPagedSubresources_NoEnvelope(t *testing.T) {
+	t.Parallel()
+
+	doc := []byte(`
+openapi: "3.0.0"
+info: { title: Plain, version: "1.0" }
+servers: [{ url: https://api.plain.example/v1 }]
+paths:
+  /things/{id}:
+    get:
+      operationId: getThing
+      parameters:
+        - { name: id, in: path, required: true, schema: { type: string } }
+      responses:
+        "200":
+          description: ok
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  id: { type: string }
+                  tags:
+                    type: array
+                    items: { type: string }
+                  metadata:
+                    type: object
+                    properties:
+                      created_at: { type: string }
+                  partial_envelope:
+                    type: object
+                    properties:
+                      items:
+                        type: array
+                        items: { type: object }
+`)
+	parsed, err := Parse(doc)
+	require.NoError(t, err)
+	ep, ok := findGetEndpoint(parsed, "/things/{id}")
+	require.True(t, ok)
+	assert.Empty(t, ep.EmbeddedPagedSubresources,
+		"plain scalars, top-level arrays, plain nested objects, and item-only envelopes without a next signal must not match")
+}
+
+// TestEmbeddedPagedSubresources_ListEndpointSkipped confirms list-style
+// endpoints (no path placeholder) are exempt from detection — they ARE
+// the paged endpoint and don't need a companion helper.
+func TestEmbeddedPagedSubresources_ListEndpointSkipped(t *testing.T) {
+	t.Parallel()
+
+	doc := []byte(`
+openapi: "3.0.0"
+info: { title: List Only, version: "1.0" }
+servers: [{ url: https://api.list.example/v1 }]
+paths:
+  /widgets:
+    get:
+      operationId: listWidgets
+      responses:
+        "200":
+          description: ok
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  data:
+                    type: array
+                    items: { type: object }
+                  has_more: { type: boolean }
+`)
+	parsed, err := Parse(doc)
+	require.NoError(t, err)
+	ep, ok := findGetEndpoint(parsed, "/widgets")
+	require.True(t, ok)
+	assert.Empty(t, ep.EmbeddedPagedSubresources,
+		"list endpoints (no path placeholder) must not surface embedded sub-resources")
+}
+
+// TestEmbeddedPagedSubresources_CursorVsURL is the runtime-correctness
+// guard: detection must distinguish full-URL next fields (next, next_url)
+// from opaque cursor tokens (next_cursor, next_page_token, cursor),
+// because the runtime helper can follow the former with a direct GET
+// but cannot construct the next request from the latter without
+// API-specific cursor-to-query-param arithmetic. Misclassifying a
+// cursor as a URL would make the runtime issue GETs against the
+// cursor string itself, producing 404s — the opposite of the silent-
+// truncation bug this fix targets.
+func TestEmbeddedPagedSubresources_CursorVsURL(t *testing.T) {
+	t.Parallel()
+
+	doc := []byte(`
+openapi: "3.0.0"
+info: { title: KindCheck, version: "1.0" }
+servers: [{ url: https://api.kind.example/v1 }]
+paths:
+  /url-shape/{id}:
+    get:
+      operationId: getURLShape
+      parameters:
+        - { name: id, in: path, required: true, schema: { type: string } }
+      responses:
+        "200":
+          description: ok
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  tracks:
+                    type: object
+                    properties:
+                      items: { type: array, items: { type: object } }
+                      next: { type: string }
+  /cursor-shape/{id}:
+    get:
+      operationId: getCursorShape
+      parameters:
+        - { name: id, in: path, required: true, schema: { type: string } }
+      responses:
+        "200":
+          description: ok
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  children:
+                    type: object
+                    properties:
+                      results: { type: array, items: { type: object } }
+                      next_cursor: { type: string }
+  /token-shape/{id}:
+    get:
+      operationId: getTokenShape
+      parameters:
+        - { name: id, in: path, required: true, schema: { type: string } }
+      responses:
+        "200":
+          description: ok
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  events:
+                    type: object
+                    properties:
+                      data: { type: array, items: { type: object } }
+                      next_page_token: { type: string }
+`)
+	parsed, err := Parse(doc)
+	require.NoError(t, err)
+
+	urlEP, ok := findGetEndpoint(parsed, "/url-shape/{id}")
+	require.True(t, ok)
+	require.Len(t, urlEP.EmbeddedPagedSubresources, 1)
+	assert.True(t, urlEP.EmbeddedPagedSubresources[0].NextIsURL,
+		"`next` is a known URL-bearing field; the runtime can follow it directly")
+	assert.False(t, urlEP.EmbeddedPagedSubresources[0].NextIsBoolean)
+
+	cursorEP, ok := findGetEndpoint(parsed, "/cursor-shape/{id}")
+	require.True(t, ok)
+	require.Len(t, cursorEP.EmbeddedPagedSubresources, 1)
+	assert.False(t, cursorEP.EmbeddedPagedSubresources[0].NextIsURL,
+		"`next_cursor` is opaque; the runtime cannot GET it as a path and must emit a truncation event")
+	assert.False(t, cursorEP.EmbeddedPagedSubresources[0].NextIsBoolean)
+
+	tokenEP, ok := findGetEndpoint(parsed, "/token-shape/{id}")
+	require.True(t, ok)
+	require.Len(t, tokenEP.EmbeddedPagedSubresources, 1)
+	assert.False(t, tokenEP.EmbeddedPagedSubresources[0].NextIsURL,
+		"`next_page_token` is opaque; same constraint as next_cursor")
+	assert.False(t, tokenEP.EmbeddedPagedSubresources[0].NextIsBoolean)
+}
+
+// TestEmbeddedPagedSubresources_CaseInsensitive guards the detector
+// against schemas that declare envelope fields in non-canonical casing
+// (PascalCase `Items` / `NextPageToken`, screaming-snake `HAS_MORE`).
+// Detection must fire on those AND preserve the wire-side casing
+// rather than rewriting it to the canonical lowercase candidate —
+// the runtime helper now compensates for casing drift on its own,
+// but the parser shouldn't paper over real schema spellings.
+func TestEmbeddedPagedSubresources_CaseInsensitive(t *testing.T) {
+	t.Parallel()
+
+	doc := []byte(`
+openapi: "3.0.0"
+info: { title: PascalCase, version: "1.0" }
+servers: [{ url: https://api.pascal.example/v1 }]
+paths:
+  /pages/{id}:
+    get:
+      operationId: getPage
+      parameters:
+        - { name: id, in: path, required: true, schema: { type: string } }
+      responses:
+        "200":
+          description: ok
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  id: { type: string }
+                  Children:
+                    type: object
+                    properties:
+                      Items:
+                        type: array
+                        items: { type: object }
+                      NextPageToken: { type: string }
+`)
+	parsed, err := Parse(doc)
+	require.NoError(t, err)
+	ep, ok := findGetEndpoint(parsed, "/pages/{id}")
+	require.True(t, ok)
+	require.Len(t, ep.EmbeddedPagedSubresources, 1)
+	got := ep.EmbeddedPagedSubresources[0]
+	assert.Equal(t, "Children", got.Property)
+	assert.Equal(t, "Items", got.ItemsField,
+		"detector must preserve the wire-side casing of the items field")
+	assert.Equal(t, "NextPageToken", got.NextField,
+		"detector must preserve the wire-side casing of the next-page field")
+}
+
+// TestEmbeddedPagedSubresources_MultipleProperties verifies the detector
+// reports every paged sub-resource on the same parent — e.g. GitHub-style
+// where a single GET response embeds both `commits` and `review_comments`
+// envelopes.
+func TestEmbeddedPagedSubresources_MultipleProperties(t *testing.T) {
+	t.Parallel()
+
+	doc := []byte(`
+openapi: "3.0.0"
+info: { title: Multi, version: "1.0" }
+servers: [{ url: https://api.multi.example/v1 }]
+paths:
+  /repos/{owner}/{repo}/pulls/{n}:
+    get:
+      operationId: getPull
+      parameters:
+        - { name: owner, in: path, required: true, schema: { type: string } }
+        - { name: repo, in: path, required: true, schema: { type: string } }
+        - { name: n, in: path, required: true, schema: { type: integer } }
+      responses:
+        "200":
+          description: ok
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  commits:
+                    type: object
+                    properties:
+                      items:
+                        type: array
+                        items: { type: object }
+                      next_page_token: { type: string }
+                  review_comments:
+                    type: object
+                    properties:
+                      data:
+                        type: array
+                        items: { type: object }
+                      has_more: { type: boolean }
+`)
+	parsed, err := Parse(doc)
+	require.NoError(t, err)
+	ep, ok := findGetEndpoint(parsed, "/repos/{owner}/{repo}/pulls/{n}")
+	require.True(t, ok)
+	require.Len(t, ep.EmbeddedPagedSubresources, 2)
+	byProp := map[string]spec.EmbeddedPagedSubresource{}
+	for _, sub := range ep.EmbeddedPagedSubresources {
+		byProp[sub.Property] = sub
+	}
+	assert.Equal(t, "/repos/{owner}/{repo}/pulls/{n}/commits", byProp["commits"].ChildPath,
+		"multi-path-param parents must propagate every placeholder into ChildPath so the runtime helper substitutes them all")
+	assert.Equal(t, "/repos/{owner}/{repo}/pulls/{n}/review_comments", byProp["review_comments"].ChildPath)
+	assert.Equal(t, "next_page_token", byProp["commits"].NextField)
+	assert.False(t, byProp["commits"].NextIsURL,
+		"next_page_token is an opaque cursor, not a URL — runtime must NOT GET it as a path")
+	assert.True(t, byProp["review_comments"].NextIsBoolean)
+}
+
+// TestItemsFieldNamesMatchRuntimeExtractor guards against silent drift
+// between detect-time (itemsFieldNames in this package) and runtime
+// (the extractPaginatedItems helper emitted into every generated CLI).
+// If one list grows a new envelope key and the other doesn't, the
+// detector either fires on shapes the helper can't handle or skips
+// shapes it could.
+func TestItemsFieldNamesMatchRuntimeExtractor(t *testing.T) {
+	t.Parallel()
+	data, err := os.ReadFile(filepath.Join("..", "generator", "templates", "helpers.go.tmpl"))
+	require.NoError(t, err)
+	for _, name := range itemsFieldNames {
+		assert.Contains(t, string(data), `"`+name+`"`,
+			"runtime extractPaginatedItems must list %q so detection and runtime walks stay in sync", name)
+	}
+}
+
+func findGetEndpoint(parsed *spec.APISpec, path string) (spec.Endpoint, bool) {
+	if parsed == nil {
+		return spec.Endpoint{}, false
+	}
+	for _, r := range parsed.Resources {
+		for _, e := range r.Endpoints {
+			if e.Method == "GET" && e.Path == path {
+				return e, true
+			}
+		}
+		for _, sub := range r.SubResources {
+			for _, e := range sub.Endpoints {
+				if e.Method == "GET" && e.Path == path {
+					return e, true
+				}
+			}
+		}
+	}
+	return spec.Endpoint{}, false
+}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 0986df19..8a0864c7 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -1874,6 +1874,12 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 			endpoint.Response, endpoint.ResponsePath = mapResponse(op, targetResourceName+"_"+endpointName, out)
 			if strings.ToUpper(method) == "GET" {
 				endpoint.Pagination = detectPagination(endpoint.Params, op)
+				// Only single-resource fetches (GET /resource/{id}) can carry
+				// embedded paged sub-resources; list endpoints ARE the paged
+				// endpoint themselves and don't need a companion helper.
+				if strings.Contains(path, "{") {
+					endpoint.EmbeddedPagedSubresources = detectEmbeddedPagedSubresources(op, path)
+				}
 			}
 			endpoint.NoAuth = operationAllowsAnonymous(op, doc)
 
@@ -4726,6 +4732,180 @@ func detectPagination(params []spec.Param, op *openapi3.Operation) *spec.Paginat
 	return &pag
 }
 
+// itemsFieldNames lists JSON property names that, when typed as an array,
+// signal "this object is a paged envelope" — matches the runtime
+// extractPaginatedItems helper in templates/helpers.go.tmpl so detect-time
+// and runtime walks agree on what counts as a page of items.
+var itemsFieldNames = []string{"data", "items", "results", "messages", "members", "values"}
+
+// nextFieldURLNames lists string properties that carry a full URL to
+// the next page (resolvable directly with GET). The runtime helper
+// follows these without API-specific cursor arithmetic.
+var nextFieldURLNames = []string{"next", "next_url", "nexturl"}
+
+// nextFieldCursorNames lists string properties that carry an opaque
+// cursor token (e.g. next_page_token, next_cursor). These cannot be
+// followed without knowing which query parameter to set on the next
+// request — metadata the embedded envelope does not provide — so the
+// runtime helper treats their presence the same way it treats
+// has_more=true: fetch page 1, then emit a truncation event so callers
+// know data is incomplete.
+var nextFieldCursorNames = []string{"next_cursor", "nextcursor", "next_page_token", "nextpagetoken", "cursor"}
+
+// nextFieldBoolNames lists boolean-typed "more pages" flags.
+var nextFieldBoolNames = []string{"has_more", "hasmore"}
+
+// detectEmbeddedPagedSubresources walks the success-response schema of a
+// GET operation looking for top-level properties whose nested shape is a
+// paged envelope. Each match yields a companion-helper candidate; the
+// caller stores the result on Endpoint.EmbeddedPagedSubresources and the
+// generator emits fetchFull<Endpoint><Property> helpers from there.
+// Returns nil when nothing matches so callers can compare against nil.
+//
+// Detection is a two-part heuristic: the property schema must be
+// object-shaped AND carry both an array items field (items/data/
+// results/...) and a next-page signal (cursor/URL string or has_more-
+// style bool).
+func detectEmbeddedPagedSubresources(op *openapi3.Operation, parentPath string) []spec.EmbeddedPagedSubresource {
+	if op == nil || op.Responses == nil {
+		return nil
+	}
+	success := selectSuccessResponse(op.Responses)
+	if success == nil || success.Value == nil {
+		return nil
+	}
+	schemaRef := selectResponseSchema(success.Value)
+	if schemaRef == nil || schemaRef.Value == nil {
+		return nil
+	}
+
+	var out []spec.EmbeddedPagedSubresource
+	propNames := make([]string, 0, len(schemaRef.Value.Properties))
+	for name := range schemaRef.Value.Properties {
+		propNames = append(propNames, name)
+	}
+	sort.Strings(propNames)
+
+	for _, propName := range propNames {
+		propRef := schemaRef.Value.Properties[propName]
+		if propRef == nil || propRef.Value == nil {
+			continue
+		}
+		propSchema := propRef.Value
+		if !schemaHasObjectShape(propSchema) {
+			continue
+		}
+		lowered := loweredPropertyMap(propSchema)
+		itemsField, ok := findItemsField(propSchema, lowered)
+		if !ok {
+			continue
+		}
+		nextField, kind := findNextField(lowered)
+		if kind == nextKindNone {
+			continue
+		}
+		out = append(out, spec.EmbeddedPagedSubresource{
+			Property:      propName,
+			ChildPath:     joinChildPath(parentPath, propName),
+			ItemsField:    itemsField,
+			NextField:     nextField,
+			NextIsURL:     kind == nextKindURL,
+			NextIsBoolean: kind == nextKindBoolean,
+		})
+	}
+	return out
+}
+
+func schemaHasObjectShape(s *openapi3.Schema) bool {
+	if s == nil {
+		return false
+	}
+	if s.Type != nil && s.Type.Is("object") {
+		return true
+	}
+	// Some specs omit `type: object` but declare properties anyway —
+	// treat that as object-shaped for detection purposes.
+	return len(s.Properties) > 0
+}
+
+// loweredPropertyMap returns a case-insensitive lookup from canonical
+// lowercase property name to the wire-side name actually declared on the
+// schema, so detection survives vendors that capitalize fields ("Items",
+// "NextPageToken") while preserving the original casing for emission.
+func loweredPropertyMap(s *openapi3.Schema) map[string]string {
+	out := make(map[string]string, len(s.Properties))
+	for name := range s.Properties {
+		out[strings.ToLower(name)] = name
+	}
+	return out
+}
+
+func findItemsField(s *openapi3.Schema, lowered map[string]string) (string, bool) {
+	if s == nil {
+		return "", false
+	}
+	for _, candidate := range itemsFieldNames {
+		actual, ok := lowered[candidate]
+		if !ok {
+			continue
+		}
+		ref := s.Properties[actual]
+		if ref == nil || ref.Value == nil {
+			continue
+		}
+		if ref.Value.Type != nil && ref.Value.Type.Is("array") {
+			return actual, true
+		}
+	}
+	return "", false
+}
+
+// nextFieldKind classifies the detected next-page signal so the
+// runtime can decide whether to follow it (URL) or stop and warn
+// (cursor / has_more bool).
+type nextFieldKind int
+
+const (
+	nextKindNone nextFieldKind = iota
+	nextKindURL
+	nextKindCursor
+	nextKindBoolean
+)
+
+func findNextField(lowered map[string]string) (string, nextFieldKind) {
+	for _, candidate := range nextFieldURLNames {
+		if actual, ok := lowered[candidate]; ok {
+			return actual, nextKindURL
+		}
+	}
+	for _, candidate := range nextFieldCursorNames {
+		if actual, ok := lowered[candidate]; ok {
+			return actual, nextKindCursor
+		}
+	}
+	for _, candidate := range nextFieldBoolNames {
+		if actual, ok := lowered[candidate]; ok {
+			return actual, nextKindBoolean
+		}
+	}
+	return "", nextKindNone
+}
+
+// joinChildPath returns parentPath + "/" + property — the conventional
+// URL shape for the dedicated child endpoint of an embedded paged
+// sub-resource (e.g. <api>/resource/{id}/<subresource>). APIs that
+// publish the sub-resource at a non-conventional URL must override via
+// the spec field after parsing.
+func joinChildPath(parentPath, property string) string {
+	if parentPath == "" || property == "" {
+		return ""
+	}
+	if strings.HasSuffix(parentPath, "/") {
+		return parentPath + property
+	}
+	return parentPath + "/" + property
+}
+
 func humanizeEndpointName(name string) string {
 	words := strings.Split(strings.ReplaceAll(name, "_", "-"), "-")
 	if len(words) == 0 {
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 7c7b411a..3b0c5296 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -1041,17 +1041,24 @@ type Endpoint struct {
 	// the parser cannot describe at field level (currently used only by
 	// the BodyJSONFallback path). The typed body path uses per-Param
 	// Required flags instead; this field is ignored when Body is populated.
-	BodyRequired       bool              `yaml:"body_required,omitempty" json:"body_required,omitempty"`
-	RequestContentType string            `yaml:"request_content_type,omitempty" json:"request_content_type,omitempty"`
-	Response           ResponseDef       `yaml:"response" json:"response"`
-	ResponseFormat     string            `yaml:"response_format,omitempty" json:"response_format,omitempty"` // json (default) or html
-	HTMLExtract        *HTMLExtract      `yaml:"html_extract,omitempty" json:"html_extract,omitempty"`       // extraction options when response_format is html
-	Pagination         *Pagination       `yaml:"pagination" json:"pagination"`
-	ResponsePath       string            `yaml:"response_path,omitempty" json:"response_path,omitempty"`       // path to extract data array from response (e.g., "data", "results.items")
-	Meta               map[string]string `yaml:"meta,omitempty" json:"meta,omitempty"`                         // per-endpoint metadata (e.g., source_tier, source_count from crowd-sniff)
-	HeaderOverrides    []RequiredHeader  `yaml:"header_overrides,omitempty" json:"header_overrides,omitempty"` // per-endpoint header overrides (e.g., different api-version)
-	NoAuth             bool              `yaml:"no_auth,omitempty" json:"no_auth,omitempty"`                   // true when the endpoint does not require authentication
-	Tier               string            `yaml:"tier,omitempty" json:"tier,omitempty"`
+	BodyRequired       bool         `yaml:"body_required,omitempty" json:"body_required,omitempty"`
+	RequestContentType string       `yaml:"request_content_type,omitempty" json:"request_content_type,omitempty"`
+	Response           ResponseDef  `yaml:"response" json:"response"`
+	ResponseFormat     string       `yaml:"response_format,omitempty" json:"response_format,omitempty"` // json (default) or html
+	HTMLExtract        *HTMLExtract `yaml:"html_extract,omitempty" json:"html_extract,omitempty"`       // extraction options when response_format is html
+	Pagination         *Pagination  `yaml:"pagination" json:"pagination"`
+	// EmbeddedPagedSubresources names paged-envelope properties nested
+	// inside this endpoint's success response (e.g. GET /<resource>/{id}
+	// where the API caps the embedded sub-resource at the first page
+	// regardless of the actual total). The generator emits a
+	// fetchFull<Endpoint><Property> companion per entry so callers
+	// needing the full child collection don't silently truncate.
+	EmbeddedPagedSubresources []EmbeddedPagedSubresource `yaml:"embedded_paged_subresources,omitempty" json:"embedded_paged_subresources,omitempty"`
+	ResponsePath              string                     `yaml:"response_path,omitempty" json:"response_path,omitempty"`       // path to extract data array from response (e.g., "data", "results.items")
+	Meta                      map[string]string          `yaml:"meta,omitempty" json:"meta,omitempty"`                         // per-endpoint metadata (e.g., source_tier, source_count from crowd-sniff)
+	HeaderOverrides           []RequiredHeader           `yaml:"header_overrides,omitempty" json:"header_overrides,omitempty"` // per-endpoint header overrides (e.g., different api-version)
+	NoAuth                    bool                       `yaml:"no_auth,omitempty" json:"no_auth,omitempty"`                   // true when the endpoint does not require authentication
+	Tier                      string                     `yaml:"tier,omitempty" json:"tier,omitempty"`
 	// IDField is the resolved primary-key field name for items returned by this
 	// endpoint, populated either by a path-item-level `x-resource-id` extension
 	// or, for OpenAPI specs, by walking the response schema (id → name → first
@@ -1296,6 +1303,24 @@ type Pagination struct {
 	HasMoreField   string `yaml:"has_more_field" json:"has_more_field"`     // response field indicating more pages (has_more)
 }
 
+// EmbeddedPagedSubresource describes one paged-envelope property nested
+// inside a parent GET response. See Endpoint.EmbeddedPagedSubresources
+// for how this drives fetchFull<X> companion-helper emission.
+//
+// ItemsField records which array key the detector matched; it is
+// detection-provenance metadata, not a runtime override. Generated
+// helpers walk the envelope via extractPaginatedItems, which scans
+// every known items-style key, so a hand-authored spec changing
+// ItemsField does not change runtime behavior.
+type EmbeddedPagedSubresource struct {
+	Property      string `yaml:"property" json:"property"`                                   // JSON property name in the parent response (e.g. "tracks")
+	ChildPath     string `yaml:"child_path" json:"child_path"`                               // sub-resource path; required, populated by the detector (parent.Path + "/" + Property)
+	ItemsField    string `yaml:"items_field" json:"items_field"`                             // array property inside the envelope; detection-provenance metadata only
+	NextField     string `yaml:"next_field" json:"next_field"`                               // next-page signal inside the envelope (URL string, opaque cursor, or has_more-style bool)
+	NextIsURL     bool   `yaml:"next_is_url,omitempty" json:"next_is_url,omitempty"`         // true when NextField carries a full URL the runtime can GET directly (vs an opaque cursor that needs API-specific arithmetic)
+	NextIsBoolean bool   `yaml:"next_is_boolean,omitempty" json:"next_is_boolean,omitempty"` // true when NextField is a has_more-style boolean rather than a cursor/URL string
+}
+
 type TypeDef struct {
 	Fields []TypeField `yaml:"fields" json:"fields"`
 }
diff --git a/testdata/golden/cases/generate-embedded-paged-api/artifacts.txt b/testdata/golden/cases/generate-embedded-paged-api/artifacts.txt
new file mode 100644
index 00000000..36446b3d
--- /dev/null
+++ b/testdata/golden/cases/generate-embedded-paged-api/artifacts.txt
@@ -0,0 +1,4 @@
+embedded-paged-api/internal/cli/helpers.go
+embedded-paged-api/internal/cli/playlists_get.go
+embedded-paged-api/internal/cli/pages_get.go
+embedded-paged-api/internal/cli/customers_get.go
diff --git a/testdata/golden/cases/generate-embedded-paged-api/command.txt b/testdata/golden/cases/generate-embedded-paged-api/command.txt
new file mode 100644
index 00000000..57d65a2f
--- /dev/null
+++ b/testdata/golden/cases/generate-embedded-paged-api/command.txt
@@ -0,0 +1,2 @@
+set -euo pipefail
+"$BINARY" generate --spec testdata/golden/fixtures/embedded-paged-api.yaml --output "$CASE_ACTUAL_DIR/embedded-paged-api" --force --spec-url file://testdata/golden/fixtures/embedded-paged-api.yaml --validate=false
diff --git a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/customers_get.go b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/customers_get.go
new file mode 100644
index 00000000..b7defffc
--- /dev/null
+++ b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/customers_get.go
@@ -0,0 +1,100 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+)
+
+func newCustomersGetCmd(flags *rootFlags) *cobra.Command {
+
+	cmd := &cobra.Command{
+		Use:         "get <id>",
+		Short:       "Get",
+		Example:     "  embedded-paged-pp-cli customers get 550e8400-e29b-41d4-a716-446655440000",
+		Annotations: map[string]string{"pp:endpoint": "customers.get", "pp:method": "GET", "pp:path": "/customers/{id}", "mcp:read-only": "true"},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if len(args) == 0 {
+				return cmd.Help()
+			}
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			path := "/customers/{id}"
+			path = replacePathParam(path, "id", args[0])
+			params := map[string]string{}
+			data, prov, err := resolveRead(cmd.Context(), c, flags, "customers", false, path, params, nil)
+			if err != nil {
+				return classifyAPIError(err, flags)
+			}
+			// Print provenance to stderr for human-facing output only.
+			// Machine-format flags (--json, --csv, --compact, --quiet, --plain,
+			// --select) and piped stdout suppress this line; the JSON envelope
+			// already carries meta.source for those consumers.
+			// SYNC: keep this gate aligned with command_promoted.go.tmpl.
+			if wantsHumanTable(cmd.OutOrStdout(), flags) {
+				var countItems []json.RawMessage
+				_ = json.Unmarshal(data, &countItems)
+				printProvenance(cmd, len(countItems), prov)
+			}
+			// For JSON output, wrap with provenance envelope before passing through flags.
+			// --select wins over --compact when both are set; --compact only runs when
+			// no explicit fields were requested. Explicit format flags (--csv, --quiet,
+			// --plain) opt out of the auto-JSON path so piped consumers that asked for
+			// a non-JSON format reach the standard pipeline below.
+			if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
+				filtered := data
+				if flags.selectFields != "" {
+					filtered = filterFields(filtered, flags.selectFields)
+				} else if flags.compact {
+					filtered = compactFields(filtered)
+				}
+				wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+				if wrapErr != nil {
+					return wrapErr
+				}
+				return printOutput(cmd.OutOrStdout(), wrapped, true)
+			}
+			// For all other output modes (table, csv, plain, quiet), use the standard pipeline
+			if wantsHumanTable(cmd.OutOrStdout(), flags) {
+				var items []map[string]any
+				if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+					if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+						return err
+					}
+					if len(items) >= 25 {
+						fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+					}
+					return nil
+				}
+			}
+			return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+		},
+	}
+
+	return cmd
+}
+
+// fetchFullCustomersGetSubscriptions returns every item
+// in the "subscriptions" sub-resource of GET /customers/{id}. The parent
+// response embeds at most the first page of this sub-resource, so any
+// caller that treats the embed as complete silently truncates; this
+// companion calls the dedicated child endpoint and paginates until
+// exhausted. pathParams must include every {placeholder} in the parent
+// path (e.g. {"id": "<value>"}).
+func fetchFullCustomersGetSubscriptions(c interface {
+	GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, pathParams map[string]string) ([]json.RawMessage, error) {
+	childPath := "/customers/{id}/subscriptions"
+	for name, val := range pathParams {
+		childPath = replacePathParam(childPath, name, val)
+	}
+	return fetchEmbeddedPagedSubresource(c, childPath, "has_more", false, true)
+}
diff --git a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
new file mode 100644
index 00000000..bfc80e0b
--- /dev/null
+++ b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
@@ -0,0 +1,1659 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+	"bytes"
+	"embedded-paged-pp-cli/internal/client"
+	"embedded-paged-pp-cli/internal/cliutil"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
+	"io"
+	"net/url"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strings"
+	"text/tabwriter"
+	"time"
+	"unicode"
+)
+
+var As = errors.As
+
+// noColor is set by the --no-color flag
+var noColor bool
+
+// humanFriendly is set by the --human-friendly flag; colors are off by default (agent-safe)
+var humanFriendly bool
+
+func colorEnabled() bool {
+	if noColor {
+		return false
+	}
+	if !humanFriendly {
+		return false
+	}
+	if os.Getenv("NO_COLOR") != "" {
+		return false
+	}
+	if os.Getenv("TERM") == "dumb" {
+		return false
+	}
+	return isTerminal(os.Stdout)
+}
+
+func isTerminal(w io.Writer) bool {
+	if f, ok := w.(*os.File); ok {
+		fi, err := f.Stat()
+		if err != nil {
+			return true
+		}
+		return (fi.Mode() & os.ModeCharDevice) != 0
+	}
+	return false
+}
+
+func bold(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[1m" + s + "\033[0m"
+}
+
+func green(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[32m" + s + "\033[0m"
+}
+
+func red(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[31m" + s + "\033[0m"
+}
+
+func yellow(s string) string {
+	if !colorEnabled() {
+		return s
+	}
+	return "\033[33m" + s + "\033[0m"
+}
+
+type cliError struct {
+	code int
+	err  error
+}
+
+func (e *cliError) Error() string { return e.err.Error() }
+func (e *cliError) Unwrap() error { return e.err }
+
+func usageErr(err error) error     { return &cliError{code: 2, err: err} }
+func notFoundErr(err error) error  { return &cliError{code: 3, err: err} }
+func authErr(err error) error      { return &cliError{code: 4, err: err} }
+func apiErr(err error) error       { return &cliError{code: 5, err: err} }
+func configErr(err error) error    { return &cliError{code: 10, err: err} }
+func rateLimitErr(err error) error { return &cliError{code: 7, err: err} }
+
+// dryRunOK reports whether the command should short-circuit without doing any
+// real work because --dry-run was set. The verify pipeline probes hand-written
+// commands with --dry-run; commands that put validation in cobra's `Args:` or
+// `MarkFlagRequired` cannot reach a dry-run guard inside RunE because cobra
+// runs those checks before RunE. The verify-friendly pattern for hand-written
+// commands is:
+//
+//	RunE: func(cmd *cobra.Command, args []string) error {
+//	    if len(args) == 0 {
+//	        return cmd.Help()
+//	    }
+//	    if dryRunOK(flags) {
+//	        return nil
+//	    }
+//	    // ... real work ...
+//	}
+//
+// See SKILL.md "Phase 3: Build The GOAT" for the full pattern.
+func dryRunOK(flags *rootFlags) bool {
+	return flags != nil && flags.dryRun
+}
+
+// accessWarning describes an API access-denial that sync converts into a
+// non-fatal warning. It carries enough structured data for the sync_warning
+// JSON event without parsing free-form error strings downstream.
+type accessWarning struct {
+	Status  int    // HTTP status when applicable; 0 for GraphQL field-level denials.
+	Reason  string // "forbidden" | "insufficient_access" | "unauthenticated"
+	Message string // human-readable detail (the API's body or GraphQL error message)
+}
+
+// syncErrorJSON returns a one-line JSON sync_error event. When err wraps a
+// *client.APIError, the structured status/method/path/body fields let
+// operators see the API's response body without parsing a wrapped error
+// string — required for diagnosing 4xx responses on endpoints whose OpenAPI
+// spec marks filter params optional but the API treats them as required.
+//
+// parent is non-empty only on the dependent-resource path, where it
+// identifies which parent ID's child request failed.
+func syncErrorJSON(resource, parent string, err error) string {
+	payload := struct {
+		Event    string `json:"event"`
+		Resource string `json:"resource"`
+		Parent   string `json:"parent,omitempty"`
+		Status   int    `json:"status,omitempty"`
+		Method   string `json:"method,omitempty"`
+		Path     string `json:"path,omitempty"`
+		Body     string `json:"body,omitempty"`
+		Error    string `json:"error"`
+	}{
+		Event:    "sync_error",
+		Resource: resource,
+		Parent:   parent,
+		Error:    err.Error(),
+	}
+	var apiErr *client.APIError
+	if errors.As(err, &apiErr) {
+		payload.Status = apiErr.StatusCode
+		payload.Method = apiErr.Method
+		payload.Path = apiErr.Path
+		payload.Body = apiErr.Body
+	}
+	out, _ := json.Marshal(payload)
+	return string(out)
+}
+
+// syncUserParams carries user-supplied query parameters injected into every
+// sync HTTP request. perResource entries win over global on key conflict.
+type syncUserParams struct {
+	global      map[string]string
+	perResource map[string]map[string]string
+}
+
+// parseSyncUserParams parses the repeatable --param key=value and
+// --resource-param resource:key=value flags. Returns usage errors keyed on the
+// specific invalid token so the user sees which entry was rejected.
+func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParams, error) {
+	p := &syncUserParams{
+		global:      map[string]string{},
+		perResource: map[string]map[string]string{},
+	}
+	for _, kv := range globalFlags {
+		k, v, ok := strings.Cut(kv, "=")
+		if !ok || k == "" {
+			return nil, fmt.Errorf("invalid --param %q: expected key=value", kv)
+		}
+		p.global[k] = v
+	}
+	for _, spec := range perResourceFlags {
+		resource, kv, ok := strings.Cut(spec, ":")
+		if !ok || resource == "" {
+			return nil, fmt.Errorf("invalid --resource-param %q: expected resource:key=value", spec)
+		}
+		k, v, ok := strings.Cut(kv, "=")
+		if !ok || k == "" {
+			return nil, fmt.Errorf("invalid --resource-param %q: expected resource:key=value", spec)
+		}
+		if p.perResource[resource] == nil {
+			p.perResource[resource] = map[string]string{}
+		}
+		p.perResource[resource][k] = v
+	}
+	return p, nil
+}
+
+// applyTo merges user params into the request map. Called after spec-derived
+// params (cursor, since, page-size, dates) so user flags can override them.
+func (p *syncUserParams) applyTo(resource string, params map[string]string) {
+	if p == nil {
+		return
+	}
+	for k, v := range p.global {
+		params[k] = v
+	}
+	for k, v := range p.perResource[resource] {
+		params[k] = v
+	}
+}
+
+// validateResourceNames returns a usage-shaped error when any --resource-param
+// key targets a resource not in known. Without this check a typo
+// (e.g. --resource-param chanels:mine=true) is a silent no-op: the param
+// never matches a real resource and sync proceeds with missing rows.
+func (p *syncUserParams) validateResourceNames(known []string) error {
+	if p == nil || len(p.perResource) == 0 {
+		return nil
+	}
+	set := make(map[string]struct{}, len(known))
+	for _, r := range known {
+		set[r] = struct{}{}
+	}
+	var unknown []string
+	for r := range p.perResource {
+		if _, ok := set[r]; !ok {
+			unknown = append(unknown, r)
+		}
+	}
+	if len(unknown) == 0 {
+		return nil
+	}
+	sort.Strings(unknown)
+	return fmt.Errorf("--resource-param references unknown resource(s): %s (known: %s)",
+		strings.Join(unknown, ", "), strings.Join(known, ", "))
+}
+
+// accessDenialPatterns matches API error bodies that indicate the request was
+// rejected for access-policy reasons rather than for input validity. Matching
+// is case-insensitive and uses word boundaries so common substrings inside
+// unrelated tokens (e.g. "author", "pagination_token", "insufficient_funds")
+// do not produce false positives. The set deliberately excludes brand names —
+// vendor-specific phrasings should be addressed at the spec/profiler level,
+// not in this universal classifier.
+var accessDenialPatterns = []*regexp.Regexp{
+	regexp.MustCompile(`\bforbidden\b`),
+	regexp.MustCompile(`\bunauthorized\b`),
+	regexp.MustCompile(`\bnot[\s_-]?authorized\b`),
+	regexp.MustCompile(`\bpermission[\s_-]?denied\b`),
+	regexp.MustCompile(`\baccess[\s_-]?denied\b`),
+	regexp.MustCompile(`\binsufficient[\s_-]?(scope|permission|privilege)`),
+	regexp.MustCompile(`\binvalid[\s_-]?scope\b`),
+	regexp.MustCompile(`\bmissing[\s_-]?scope\b`),
+	regexp.MustCompile(`\brequires?\s+(elevated|admin|enterprise|business|workspace|enterprise[\s_-]?tier)`),
+}
+
+// looksLikeAccessDenial reports whether body text describes an access-policy
+// rejection. Use it on response-body content (apiErr.Body), not on the full
+// error string — the request path can contain words like "auth" or "tokens"
+// that would produce false positives if the whole error message were scanned.
+func looksLikeAccessDenial(body string) bool {
+	lower := strings.ToLower(body)
+	for _, p := range accessDenialPatterns {
+		if p.MatchString(lower) {
+			return true
+		}
+	}
+	return false
+}
+
+// isSyncAccessWarning classifies err as an access-denial warning suitable for
+// sync's warn-and-continue path. It returns nil, false for any error that
+// should remain a hard sync failure: HTTP 401 (token-level auth failure
+// requiring re-auth), 5xx, network errors, and HTTP 400 responses whose
+// bodies do not match an access-policy pattern.
+//
+// Recognized warning shapes:
+//   - HTTP 403 (per-resource ACL rejection)
+//   - HTTP 400 + access-denial body keyword (insufficient scope, etc.)
+//   - GraphQL response carrying only access-denial extension codes
+func isSyncAccessWarning(err error) (*accessWarning, bool) {
+	if err == nil {
+		return nil, false
+	}
+
+	var apiErr *client.APIError
+	if errors.As(err, &apiErr) {
+		switch apiErr.StatusCode {
+		case 403:
+			return &accessWarning{Status: 403, Reason: "forbidden", Message: apiErr.Body}, true
+		case 400:
+			if looksLikeAccessDenial(apiErr.Body) {
+				return &accessWarning{Status: 400, Reason: "insufficient_access", Message: apiErr.Body}, true
+			}
+		}
+	}
+
+	return nil, false
+}
+
+type noopResult struct {
+	Status string `json:"status"`
+	Reason string `json:"reason"`
+}
+
+func writeNoop(flags *rootFlags, reason, prose string) error {
+	if flags != nil && flags.asJSON {
+		return json.NewEncoder(os.Stdout).Encode(noopResult{Status: "noop", Reason: reason})
+	}
+	fmt.Fprintln(os.Stderr, prose)
+	return nil
+}
+
+func writeAPIErrorEnvelope(flags *rootFlags, err error, code int) {
+	if flags == nil || !flags.asJSON {
+		return
+	}
+	_ = json.NewEncoder(os.Stdout).Encode(map[string]any{
+		"error": err.Error(),
+		"code":  code,
+	})
+}
+
+// classifyAPIError maps API errors to structured exit codes with actionable hints.
+func classifyAPIError(err error, flags *rootFlags) error {
+	msg := err.Error()
+	switch {
+	case strings.Contains(msg, "HTTP 409"):
+		if flags != nil && flags.idempotent {
+			return writeNoop(flags, "already_exists", "already exists (no-op)")
+		}
+		classified := apiErr(err)
+		writeAPIErrorEnvelope(flags, classified, ExitCode(classified))
+		return classified
+	case strings.Contains(msg, "HTTP 400") && cliutil.LooksLikeAuthError(msg):
+		return authErr(fmt.Errorf("%w\nhint: the API rejected the request — this usually means auth is missing or invalid."+
+			"\n      Set your API key: export EMBEDDED_PAGED_API_KEY=<your-key>"+
+			"\n      Run 'embedded-paged-pp-cli doctor' to check auth status."+
+			"\n      Response: "+cliutil.SanitizeErrorBody(msg), err))
+	case strings.Contains(msg, "HTTP 401"):
+		return authErr(fmt.Errorf("%w\nhint: check your API key."+
+			" Set it with: export EMBEDDED_PAGED_API_KEY=<your-key>"+
+			"\n      Run 'embedded-paged-pp-cli doctor' to check auth status.", err))
+	case strings.Contains(msg, "HTTP 403"):
+		return authErr(fmt.Errorf("%w\nhint: permission denied. Your credentials are valid but lack access to this resource."+
+			"\n      Check that your API key has the required permissions."+
+			"\n      Set it with: export EMBEDDED_PAGED_API_KEY=<your-key>"+
+			"\n      Run 'embedded-paged-pp-cli doctor' to check auth status.", err))
+	case strings.Contains(msg, "HTTP 404"):
+		return notFoundErr(fmt.Errorf("%w\nhint: resource not found. Run the 'list' command to see available items", err))
+	case strings.Contains(msg, "HTTP 429"):
+		return rateLimitErr(err)
+	default:
+		return apiErr(err)
+	}
+}
+
+func truncate(s string, max int) string {
+	if len(s) <= max {
+		return s
+	}
+	if max <= 3 {
+		return s[:max]
+	}
+	return s[:max-3] + "..."
+}
+
+func newTabWriter(w io.Writer) *tabwriter.Writer {
+	return tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
+}
+func replacePathParam(path, name, value string) string {
+	return strings.ReplaceAll(path, "{"+name+"}", value)
+}
+
+// paginatedGet fetches pages and concatenates array results. The headers
+// argument carries per-endpoint required headers (e.g. cal-api-version) that
+// must be sent on every page request, including the first; pass nil when the
+// endpoint has no per-endpoint header overrides.
+func paginatedGet(c interface {
+	GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, path string, params map[string]string, headers map[string]string, fetchAll bool, cursorParam, nextCursorPath, hasMoreField string) (json.RawMessage, error) {
+	// Cursor params are exempt from the "0"/"false" strip: offset-paginated
+	// APIs send offset=0 on the first page.
+	clean := map[string]string{}
+	for k, v := range params {
+		if v == "" {
+			continue
+		}
+		if k == cursorParam || (v != "0" && v != "false") {
+			clean[k] = v
+		}
+	}
+
+	if !fetchAll {
+		data, err := c.GetWithHeaders(path, clean, headers)
+		if err != nil {
+			return nil, err
+		}
+		emitTruncationWarning(data, nextCursorPath, hasMoreField)
+		return data, nil
+	}
+
+	// Fetch all pages
+	allItems := make([]json.RawMessage, 0)
+	page := 0
+	for {
+		page++
+		if humanFriendly {
+			fmt.Fprintf(os.Stderr, "fetching page %d...\n", page)
+		} else {
+			fmt.Fprintf(os.Stderr, `{"event":"page_fetch","page":%d}`+"\n", page)
+		}
+
+		data, err := c.GetWithHeaders(path, clean, headers)
+		if err != nil {
+			return nil, err
+		}
+
+		// Try to extract items array
+		var items []json.RawMessage
+		if json.Unmarshal(data, &items) == nil {
+			allItems = append(allItems, items...)
+		} else {
+			// Response is an object - look for array inside
+			var obj map[string]json.RawMessage
+			if json.Unmarshal(data, &obj) == nil {
+				if nested, ok := extractPaginatedItems(obj); ok {
+					allItems = append(allItems, nested...)
+				}
+
+				// Check for next cursor
+				if nextCursorPath != "" {
+					if tokenRaw, ok := rawAtPath(obj, nextCursorPath); ok {
+						var token string
+						if json.Unmarshal(tokenRaw, &token) == nil && token != "" {
+							clean[cursorParam] = token
+							continue
+						}
+					}
+				}
+
+				// Check has_more
+				if hasMoreField != "" {
+					if moreRaw, ok := rawAtPath(obj, hasMoreField); ok {
+						var more bool
+						if json.Unmarshal(moreRaw, &more) == nil && more {
+							continue
+						}
+					}
+				}
+			}
+			// No more pages
+			break
+		}
+
+		// For direct arrays, can't paginate without cursor
+		break
+	}
+
+	if humanFriendly {
+		fmt.Fprintf(os.Stderr, "fetched %d items across %d pages\n", len(allItems), page)
+	} else {
+		fmt.Fprintf(os.Stderr, `{"event":"complete","total":%d,"pages":%d}`+"\n", len(allItems), page)
+	}
+	result, _ := json.Marshal(allItems)
+	return json.RawMessage(result), nil
+}
+
+// Silent page-1 truncation is the worst-possible mode for agents,
+// who otherwise compute totals against an incomplete set without
+// passing --all.
+func emitTruncationWarning(data json.RawMessage, nextCursorPath, hasMoreField string) {
+	if nextCursorPath == "" && hasMoreField == "" {
+		return
+	}
+	var obj map[string]json.RawMessage
+	if err := json.Unmarshal(data, &obj); err != nil {
+		return
+	}
+	var nextCursor string
+	if nextCursorPath != "" {
+		if tokenRaw, ok := rawAtPath(obj, nextCursorPath); ok {
+			_ = json.Unmarshal(tokenRaw, &nextCursor)
+		}
+	}
+	var hasMore bool
+	if hasMoreField != "" {
+		if moreRaw, ok := rawAtPath(obj, hasMoreField); ok {
+			_ = json.Unmarshal(moreRaw, &hasMore)
+		}
+	}
+	if nextCursor == "" && !hasMore {
+		return
+	}
+	// --all only advances when a next-cursor is configured. has_more-only
+	// endpoints have no cursor to set on the next page, so the --all loop
+	// re-fetches the same response forever. Don't advertise an escape
+	// hatch that doesn't work for this topology.
+	if nextCursor != "" {
+		if humanFriendly {
+			fmt.Fprintf(os.Stderr, "warning: results truncated; more pages available. Re-run with --all to fetch every page.\n")
+		} else {
+			fmt.Fprintf(os.Stderr, `{"event":"truncated","hint":"pass --all to fetch every page"}`+"\n")
+		}
+		return
+	}
+	if humanFriendly {
+		fmt.Fprintf(os.Stderr, "warning: results truncated; more pages available.\n")
+	} else {
+		fmt.Fprintf(os.Stderr, `{"event":"truncated"}`+"\n")
+	}
+}
+
+func extractPaginatedItems(obj map[string]json.RawMessage) ([]json.RawMessage, bool) {
+	for _, field := range []string{"data", "items", "results", "messages", "members", "values"} {
+		if arr, ok := obj[field]; ok {
+			var nested []json.RawMessage
+			if json.Unmarshal(arr, &nested) == nil {
+				return nested, true
+			}
+		}
+	}
+
+	var onlyArray []json.RawMessage
+	arrayCount := 0
+	for _, raw := range obj {
+		var candidate []json.RawMessage
+		if json.Unmarshal(raw, &candidate) == nil {
+			onlyArray = candidate
+			arrayCount++
+		}
+	}
+	if arrayCount == 1 {
+		return onlyArray, true
+	}
+	return nil, false
+}
+
+// embeddedPagedSubresourcePageCap bounds the page loop in
+// fetchEmbeddedPagedSubresource so a misbehaving server returning a
+// non-empty next URL on every response can't spin forever. 500 covers
+// the largest realistic embedded sub-resource (e.g. 50k items at 100
+// per page); pathological servers trip the cap and surface an error
+// instead of hanging.
+const embeddedPagedSubresourcePageCap = 500
+
+// fetchEmbeddedPagedSubresource paginates the embedded paged
+// sub-resource at childPath until exhausted, returning the
+// concatenated items. The generator emits per-endpoint thin wrappers
+// that call this with the detected envelope shape; consolidating the
+// loop here keeps the page cap, error handling, and envelope-walking
+// logic in one place.
+//
+// nextField names the envelope's "more pages" property. nextIsURL
+// distinguishes the two cursor shapes:
+//
+//   - nextIsURL=true: the property carries a full URL the runtime can
+//     GET directly to fetch the next page (envelope shape: items+next).
+//   - nextIsURL=false: the property either carries an opaque cursor
+//     token whose required query-param name isn't part of the embedded
+//     envelope, or it's a has_more-style bool. In either case the
+//     helper performs a single GET and emits a stderr truncation event
+//     when the field indicates more pages exist — paginating further
+//     requires API-specific arithmetic that cannot be synthesized
+//     from the envelope alone, so callers must use the dedicated list
+//     endpoint for full traversal.
+//
+// Envelope lookups are case-insensitive: API authors sometimes
+// describe envelopes in one casing in the schema and serve another on
+// the wire (`NextPageToken` vs `nextPageToken`).
+func fetchEmbeddedPagedSubresource(c interface {
+	GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, childPath, nextField string, nextIsURL, nextIsBoolean bool) ([]json.RawMessage, error) {
+	all := make([]json.RawMessage, 0)
+	nextURL := ""
+	for page := 0; page < embeddedPagedSubresourcePageCap; page++ {
+		target := childPath
+		if nextURL != "" {
+			target = relativizeNextURL(nextURL)
+		}
+		data, err := c.GetWithHeaders(target, nil, nil)
+		if err != nil {
+			return nil, fmt.Errorf("paginating embedded sub-resource %q page %d: %w", childPath, page+1, err)
+		}
+		var obj map[string]json.RawMessage
+		if err := json.Unmarshal(data, &obj); err != nil {
+			return nil, fmt.Errorf("paginating embedded sub-resource %q page %d: unexpected response shape: %w", childPath, page+1, err)
+		}
+		if items, ok := extractPaginatedItems(obj); ok {
+			all = append(all, items...)
+		}
+		if !nextIsURL {
+			if moreRaw, ok := caseInsensitiveLookup(obj, nextField); ok {
+				if hasUnfollowableNextSignal(moreRaw, nextIsBoolean) {
+					emitEmbeddedPagedTruncationWarning(childPath)
+				}
+			}
+			return all, nil
+		}
+		nextRaw, ok := caseInsensitiveLookup(obj, nextField)
+		if !ok {
+			return all, nil
+		}
+		var nextStr string
+		if json.Unmarshal(nextRaw, &nextStr) != nil || nextStr == "" {
+			return all, nil
+		}
+		nextURL = nextStr
+	}
+	return all, fmt.Errorf("paginating embedded sub-resource %q: hit %d-page cap (likely a server returning a non-empty next URL on every response)", childPath, embeddedPagedSubresourcePageCap)
+}
+
+// hasUnfollowableNextSignal reports whether a non-URL next field
+// value indicates more pages exist (true bool, non-empty cursor
+// string) — the caller emits a truncation warning when this returns
+// true. Returns false on missing/null/false/empty signals.
+func hasUnfollowableNextSignal(raw json.RawMessage, nextIsBoolean bool) bool {
+	if nextIsBoolean {
+		var more bool
+		return json.Unmarshal(raw, &more) == nil && more
+	}
+	var cursor string
+	return json.Unmarshal(raw, &cursor) == nil && cursor != ""
+}
+
+func emitEmbeddedPagedTruncationWarning(childPath string) {
+	if humanFriendly {
+		fmt.Fprintf(os.Stderr, "warning: %s truncated; more pages available via the dedicated list endpoint.\n", childPath)
+	} else {
+		fmt.Fprintf(os.Stderr, `{"event":"truncated","sub_resource":%q}`+"\n", childPath)
+	}
+}
+
+// relativizeNextURL strips an absolute scheme+host from a next-page URL
+// so the client's BaseURL+path concat doesn't produce a double-prefixed
+// target. Vendors that return absolute next URLs (e.g. for offset-based
+// pagination) would otherwise concatenate against c.BaseURL on every
+// follow-up request and 404 from page 2 onward.
+func relativizeNextURL(raw string) string {
+	u, err := url.Parse(raw)
+	if err != nil || !u.IsAbs() {
+		return raw
+	}
+	path := u.Path
+	if path == "" {
+		path = "/"
+	}
+	if u.RawQuery != "" {
+		path += "?" + u.RawQuery
+	}
+	return path
+}
+
+// caseInsensitiveLookup returns obj[key] tolerating envelope casing
+// drift between the spec and the wire response.
+func caseInsensitiveLookup(obj map[string]json.RawMessage, key string) (json.RawMessage, bool) {
+	if raw, ok := obj[key]; ok {
+		return raw, true
+	}
+	lowered := strings.ToLower(key)
+	for k, raw := range obj {
+		if strings.ToLower(k) == lowered {
+			return raw, true
+		}
+	}
+	return nil, false
+}
+
+func rawAtPath(obj map[string]json.RawMessage, path string) (json.RawMessage, bool) {
+	if raw, ok := obj[path]; ok {
+		return raw, true
+	}
+
+	current := obj
+	parts := strings.Split(path, ".")
+	for i, part := range parts {
+		raw, ok := current[part]
+		if !ok {
+			return nil, false
+		}
+		if i == len(parts)-1 {
+			return raw, true
+		}
+		if err := json.Unmarshal(raw, &current); err != nil {
+			return nil, false
+		}
+	}
+	return nil, false
+}
+
+// printJSONFiltered marshals a Go-typed value through the same output
+// pipeline endpoint-mirror commands use. Hand-written novel commands that
+// build a typed slice/struct call this so --select, --compact, --csv, and
+// --quiet all behave the same way as on generator-emitted commands.
+func printJSONFiltered(w io.Writer, v any, flags *rootFlags) error {
+	raw, err := json.Marshal(v)
+	if err != nil {
+		return err
+	}
+	return printOutputWithFlags(w, json.RawMessage(raw), flags)
+}
+
+// filterFields keeps only the specified fields (comma-separated) from JSON objects/arrays.
+// Supports dotted paths like "events.shortName" to descend into nested structures.
+// Arrays are traversed element-wise: "events.shortName" keeps shortName on each event.
+func filterFields(data json.RawMessage, fields string) json.RawMessage {
+	var paths [][]string
+	for _, f := range strings.Split(fields, ",") {
+		f = strings.TrimSpace(f)
+		if f == "" {
+			continue
+		}
+		parts := strings.Split(f, ".")
+		for i := range parts {
+			parts[i] = strings.ToLower(parts[i])
+		}
+		paths = append(paths, parts)
+	}
+	if len(paths) == 0 {
+		return data
+	}
+	return filterFieldsRec(data, paths)
+}
+
+// filterFieldsRec applies path filters to a JSON value. Each path is a list of
+// lowercase segments; arrays descend element-wise.
+func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
+	var arr []json.RawMessage
+	if err := json.Unmarshal(data, &arr); err == nil {
+		out := make([]json.RawMessage, len(arr))
+		for i, el := range arr {
+			out[i] = filterFieldsRec(el, paths)
+		}
+		result, _ := json.Marshal(out)
+		return result
+	}
+
+	var obj map[string]json.RawMessage
+	if err := json.Unmarshal(data, &obj); err == nil {
+		keepWhole := map[string]bool{}
+		subPaths := map[string][][]string{}
+		for _, p := range paths {
+			if len(p) == 0 {
+				continue
+			}
+			head := p[0]
+			if len(p) == 1 {
+				keepWhole[head] = true
+			} else {
+				subPaths[head] = append(subPaths[head], p[1:])
+			}
+		}
+		filtered := map[string]json.RawMessage{}
+		for k, v := range obj {
+			matched := matchSelectSegment(k, keepWhole, subPaths)
+			if matched == "" {
+				continue
+			}
+			if keepWhole[matched] {
+				filtered[k] = v
+				continue
+			}
+			if subs := subPaths[matched]; subs != nil {
+				filtered[k] = filterFieldsRec(v, subs)
+			}
+		}
+		result, _ := json.Marshal(filtered)
+		return result
+	}
+
+	return data
+}
+
+// matchSelectSegment returns the matching lowercase segment, or "" if no match.
+// Supports direct case-insensitive match and camelCase→kebab-case conversion.
+func matchSelectSegment(fieldName string, keepWhole map[string]bool, subPaths map[string][][]string) string {
+	lower := strings.ToLower(fieldName)
+	if keepWhole[lower] || subPaths[lower] != nil {
+		return lower
+	}
+	kebab := camelToKebab(fieldName)
+	if kebab != lower && (keepWhole[kebab] || subPaths[kebab] != nil) {
+		return kebab
+	}
+	return ""
+}
+
+// camelToKebab converts "orderDate" or "orderdate" to "order-date" by splitting on
+// uppercase boundaries. For already-lowercase input, splits on known word boundaries.
+func camelToKebab(s string) string {
+	var b strings.Builder
+	runes := []rune(s)
+	for i, r := range runes {
+		if i > 0 && unicode.IsUpper(r) && unicode.IsLower(runes[i-1]) {
+			b.WriteByte('-')
+		}
+		b.WriteRune(unicode.ToLower(r))
+	}
+	return b.String()
+}
+
+// printOutputWithFlags routes output through the right format based on flags.
+func printOutputWithFlags(w io.Writer, data json.RawMessage, flags *rootFlags) error {
+	// --select wins over --compact when both are set: an explicit field list
+	// is the user's authoritative request, so the high-gravity allow-list
+	// must not strip those fields out before --select can pick them. When
+	// only --compact is set (e.g., --agent without --select), the allow-list
+	// still runs.
+	if flags.selectFields != "" {
+		data = filterFields(data, flags.selectFields)
+	} else if flags.compact {
+		data = compactFields(data)
+	}
+	// --quiet: suppress all output, exit code communicates result
+	if flags.quiet {
+		return nil
+	}
+	// --csv: render as CSV
+	if flags.csv {
+		return printCSV(w, data)
+	}
+	return printOutput(w, data, flags.asJSON)
+}
+
+// extractResponseData unwraps common API response envelopes for display.
+// Many APIs return {"status":"success","data":[...]} instead of a bare array.
+// This extracts the inner data for output helpers (filterFields, compactFields,
+// printAutoTable) that expect arrays or flat objects.
+//
+// Only unwraps when a "status" field is present and indicates success — this
+// avoids false positives on APIs where "data" is a regular field (e.g., Stripe
+// returns {"data":[...],"has_more":true} where "data" is the list, not an
+// envelope wrapper).
+func extractResponseData(data json.RawMessage) json.RawMessage {
+	var envelope struct {
+		Status string          `json:"status"`
+		Data   json.RawMessage `json:"data"`
+	}
+	if err := json.Unmarshal(data, &envelope); err != nil {
+		return data
+	}
+	if envelope.Data == nil || envelope.Status == "" {
+		return data // No status field = not an envelope, might be regular "data" field
+	}
+	switch envelope.Status {
+	case "success", "ok", "OK", "Success":
+		return envelope.Data
+	default:
+		return data
+	}
+}
+
+// compactVerboseFields are the prose-shaped fields stripped by both the
+// list and single-object compact paths. Centralised so the contract stays
+// in lockstep across both helpers.
+var compactVerboseFields = map[string]bool{
+	"description": true, "body": true, "content": true,
+	"comments": true, "attachments": true, "html": true, "markdown": true,
+}
+
+// compactFields keeps only the most important fields for agent consumption.
+// For arrays: allowlist of high-gravity fields (no descriptions).
+// For single objects: blocklist that strips known-verbose fields (descriptions, comments, etc.).
+func compactFields(data json.RawMessage) json.RawMessage {
+	// Try array first
+	var items []map[string]any
+	if err := json.Unmarshal(data, &items); err == nil {
+		return compactListFields(items)
+	}
+
+	// Single object — use blocklist
+	var obj map[string]any
+	if err := json.Unmarshal(data, &obj); err == nil {
+		return compactObjectFields(obj)
+	}
+
+	return data
+}
+
+// compactListFields keeps only high-gravity fields for array responses.
+//
+// Two-layer keep rule:
+//
+//  1. A static allow-list covers canonical scalars (id/name/price/status/...).
+//  2. A data-driven extension also keeps any scalar key present in at least
+//     80% of input rows. This catches hand-written novel commands whose
+//     output keys (object_name, match_key, snippet) aren't on the canonical
+//     allow-list, without forcing every printed CLI to expand the list.
+//
+// Verbose fields (description, body, content, etc.) are excluded from the
+// data-driven extension regardless of frequency, so the compact intent
+// (short identifying values for agent consumption, not full prose) is
+// preserved.
+//
+// When an item still carries none of the keep keys, the original is
+// preserved so `--agent` does not silently emit {} for shapes whose key
+// names are entirely off-canonical.
+func compactListFields(items []map[string]any) json.RawMessage {
+	keepFields := map[string]bool{
+		// Identity
+		"id": true, "name": true, "title": true, "identifier": true,
+		"code": true, "slug": true, "key": true,
+		// Categorization
+		"status": true, "state": true, "type": true, "kind": true, "priority": true,
+		// Communication
+		"url": true, "email": true,
+		// Monetary
+		"price": true, "amount": true, "cost": true, "fare": true,
+		"rate": true, "currency": true,
+		// Metrics
+		"rating": true, "score": true, "count": true,
+		// Locale / geo
+		"language": true, "locale": true, "country": true, "region": true,
+		"city": true, "domain": true,
+		// Temporal
+		"created_at": true, "updated_at": true, "createdAt": true, "updatedAt": true,
+		"date": true,
+		// Versioning
+		"version": true,
+	}
+	if len(items) > 0 {
+		keyCounts := map[string]int{}
+		for _, item := range items {
+			for k, v := range item {
+				if compactVerboseFields[k] || !isCompactScalar(v) {
+					continue
+				}
+				keyCounts[k]++
+			}
+		}
+		// ceil(len(items) * 0.8) without importing math. Capped at len-1 for
+		// len >= 2 so a single missing row cannot veto a key on small lists
+		// (without the cap, ceil(0.8*n) == n for n in {2,3,4}, which silently
+		// reintroduces the partial-strip bug whenever a heterogeneous 2-4 row
+		// response mixes one allow-list key with novel keys).
+		threshold := (len(items)*4 + 4) / 5
+		if len(items) >= 2 && threshold > len(items)-1 {
+			threshold = len(items) - 1
+		}
+		for k, count := range keyCounts {
+			if count >= threshold {
+				keepFields[k] = true
+			}
+		}
+	}
+
+	filtered := make([]map[string]any, 0, len(items))
+	for _, item := range items {
+		compact := map[string]any{}
+		for k, v := range item {
+			if keepFields[k] {
+				compact[k] = v
+			}
+		}
+		if len(compact) == 0 {
+			compact = item
+		}
+		filtered = append(filtered, compact)
+	}
+	result, _ := json.Marshal(filtered)
+	return result
+}
+
+// isCompactScalar reports whether v is a small primitive (string, number,
+// bool, null) suitable for --compact projection. Objects and arrays are
+// rejected: they almost always represent nested structure that defeats the
+// "short identifying value" intent of --compact, and including them in the
+// data-driven keep set would bloat agent output.
+func isCompactScalar(v any) bool {
+	switch v.(type) {
+	case nil, bool, float64, string:
+		return true
+	default:
+		return false
+	}
+}
+
+// compactObjectFields strips known-verbose fields from single-object responses.
+// Uses a blocklist so it works across all API domains (project management, payments, CRM, etc.).
+func compactObjectFields(obj map[string]any) json.RawMessage {
+	compact := map[string]any{}
+	for k, v := range obj {
+		if !compactVerboseFields[k] {
+			compact[k] = v
+		}
+	}
+	result, _ := json.Marshal(compact)
+	return result
+}
+
+// printCSV renders JSON arrays as CSV with header row.
+func printCSV(w io.Writer, data json.RawMessage) error {
+	var items []map[string]any
+	if err := json.Unmarshal(data, &items); err != nil || len(items) == 0 {
+		// Single object or empty - just print as JSON
+		fmt.Fprintln(w, string(data))
+		return nil
+	}
+	// Collect all keys for header
+	keySet := map[string]bool{}
+	for _, item := range items {
+		for k := range item {
+			keySet[k] = true
+		}
+	}
+	var keys []string
+	for k := range keySet {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+	// Header
+	fmt.Fprintln(w, strings.Join(keys, ","))
+	// Rows
+	for _, item := range items {
+		var vals []string
+		for _, k := range keys {
+			v := item[k]
+			if v == nil {
+				vals = append(vals, "")
+			} else {
+				s := fmt.Sprintf("%v", v)
+				if strings.ContainsAny(s, ",\"\n") {
+					s = `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
+				}
+				vals = append(vals, s)
+			}
+		}
+		fmt.Fprintln(w, strings.Join(vals, ","))
+	}
+	return nil
+}
+
+// printOutput auto-detects arrays and renders as tables, or prints raw JSON for objects.
+func printOutput(w io.Writer, data json.RawMessage, asJSON bool) error {
+	if !asJSON && !isTerminal(w) {
+		asJSON = true
+	}
+
+	if asJSON {
+		enc := json.NewEncoder(w)
+		enc.SetIndent("", "  ")
+		return enc.Encode(data)
+	}
+
+	// Try to detect if response is an array
+	var items []map[string]any
+	if err := json.Unmarshal(data, &items); err == nil && len(items) > 0 {
+		if err := printAutoTable(w, items); err != nil {
+			return err
+		}
+		// Agent-friendly: show count and suggest narrowing when results are large
+		if len(items) >= 25 {
+			fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+		}
+		return nil
+	}
+
+	// Single object - pretty print
+	var obj map[string]any
+	if err := json.Unmarshal(data, &obj); err == nil {
+		enc := json.NewEncoder(w)
+		enc.SetIndent("", "  ")
+		return enc.Encode(obj)
+	}
+
+	// Fallback: print raw
+	fmt.Fprintln(w, string(data))
+	return nil
+}
+
+// levenshteinDistance computes the edit distance between two strings using a two-row DP approach.
+func levenshteinDistance(a, b string) int {
+	if len(a) == 0 {
+		return len(b)
+	}
+	if len(b) == 0 {
+		return len(a)
+	}
+	if len(a) < len(b) {
+		a, b = b, a
+	}
+	prev := make([]int, len(b)+1)
+	curr := make([]int, len(b)+1)
+	for j := range prev {
+		prev[j] = j
+	}
+	for i := 1; i <= len(a); i++ {
+		curr[0] = i
+		for j := 1; j <= len(b); j++ {
+			cost := 1
+			if a[i-1] == b[j-1] {
+				cost = 0
+			}
+			ins := curr[j-1] + 1
+			del := prev[j] + 1
+			sub := prev[j-1] + cost
+			min := ins
+			if del < min {
+				min = del
+			}
+			if sub < min {
+				min = sub
+			}
+			curr[j] = min
+		}
+		prev, curr = curr, prev
+	}
+	return prev[len(b)]
+}
+
+// suggestFlag returns the closest known flag name to the unknown string, or "" if none is close enough.
+func suggestFlag(unknown string, cmd *cobra.Command) string {
+	unknown = strings.TrimLeft(unknown, "-")
+	best := ""
+	bestDist := 4 // only consider distance <= 3
+	check := func(name string) {
+		d := levenshteinDistance(unknown, name)
+		if d < bestDist && d*5 <= len(unknown)*2 {
+			bestDist = d
+			best = name
+		}
+	}
+	cmd.Flags().VisitAll(func(f *pflag.Flag) {
+		check(f.Name)
+	})
+	cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {
+		check(f.Name)
+	})
+	return best
+}
+
+// wantsHumanTable returns true when output should be a human-friendly table.
+// Smart default: terminal=table, pipe=JSON.
+// - Human in terminal: isTerminal()=true → table
+// - Claude Code/Codex bash tool: stdout piped → JSON
+// - --json/--csv/--compact/--agent: machine format → JSON
+func wantsHumanTable(w io.Writer, flags *rootFlags) bool {
+	if flags.asJSON || flags.csv || flags.compact || flags.quiet || flags.plain {
+		return false
+	}
+	if flags.selectFields != "" {
+		return false
+	}
+	return isTerminal(w)
+}
+
+func printAutoTable(w io.Writer, items []map[string]any) error {
+	if len(items) == 0 {
+		return nil
+	}
+
+	// Count scalar vs complex fields to decide format
+	scalarCount := 0
+	for _, v := range items[0] {
+		if isCompactScalar(v) {
+			scalarCount++
+		}
+	}
+
+	// Use sectional/card layout for complex items (many fields or nested data)
+	if len(items[0]) > 8 || scalarCount < len(items[0])-2 {
+		return printAutoCards(w, items)
+	}
+
+	headers := prioritizeHeaders(items[0])
+
+	// Limit to 6 columns max for readability
+	if len(headers) > 6 {
+		headers = headers[:6]
+	}
+
+	// Build rows
+	rows := make([][]string, 0, len(items))
+	for _, item := range items {
+		row := make([]string, len(headers))
+		for i, h := range headers {
+			row[i] = formatCellValue(item[h])
+		}
+		rows = append(rows, row)
+	}
+
+	// Print with tab alignment using tabwriter
+	tw := newTabWriter(w)
+	upperHeaders := make([]string, len(headers))
+	for i, h := range headers {
+		upperHeaders[i] = bold(strings.ToUpper(h))
+	}
+
+	fmt.Fprintln(tw, strings.Join(upperHeaders, "\t"))
+	for _, row := range rows {
+		fmt.Fprintln(tw, strings.Join(row, "\t"))
+	}
+	return tw.Flush()
+}
+
+// prioritizeHeaders orders scalar fields by importance for table display.
+func prioritizeHeaders(item map[string]any) []string {
+	return prioritizeFields(item, false)
+}
+
+// prioritizeAllHeaders orders all fields (including arrays) by importance for card display.
+func prioritizeAllHeaders(item map[string]any) []string {
+	return prioritizeFields(item, true)
+}
+
+// prioritizeFields orders fields by importance: identity → temporal → status → other.
+// When includeComplex is true, arrays and objects are included (for card layout).
+//
+// Uses exact-or-suffix matching to avoid false positives: "name" matches "Name" and
+// "UserName" but not "BuildingName" (because "Building" is not a known prefix that
+// indicates identity). The field is split on camelCase/snake_case boundaries and the
+// LAST segment is matched against patterns.
+func prioritizeFields(item map[string]any, includeComplex bool) []string {
+	// Priority tiers — matched against the last segment of the field name.
+	// "OrderDate" → last segment "date" → tier 1 (temporal).
+	// "BuildingName" → last segment "name" → tier 0... but we want to avoid this.
+	// Solution: exact match on the full lowered name OR suffix segment match,
+	// with a penalty for compound names that have a non-identity prefix.
+	type pattern struct {
+		word string
+		tier int
+	}
+	// Exact matches (full field name, case-insensitive) — highest confidence
+	exactMatches := map[string]int{
+		"id": 0, "name": 0, "title": 0, "slug": 0, "key": 0,
+		"date": 1, "created": 1, "updated": 1, "createdat": 1, "updatedat": 1,
+		"status": 2, "state": 2, "statuscode": 2,
+		"summary": 3, "description": 3, "price": 3, "amount": 3, "total": 3,
+		"cost": 3, "points": 3, "score": 3,
+		"type": 4, "kind": 4, "category": 4, "email": 4, "phone": 4, "url": 4,
+	}
+	// Suffix patterns — match when the field ends with this word (after splitting)
+	suffixMatches := map[string]int{
+		"id": 0, "name": 0, "title": 0,
+		"date": 1, "time": 1,
+		"status": 2, "state": 2, "code": 2,
+		"price": 3, "amount": 3, "total": 3, "cost": 3,
+		"summary": 3, "description": 3, "points": 3, "score": 3,
+		"type": 4, "kind": 4, "category": 4, "method": 4,
+	}
+
+	numTiers := 5
+
+	type scored struct {
+		name  string
+		tier  int
+		index int
+	}
+
+	var all []scored
+	idx := 0
+	for k, v := range item {
+		if !includeComplex {
+			switch v.(type) {
+			case []any, map[string]any:
+				continue
+			}
+		}
+		// Skip values that won't render usefully in cards
+		if includeComplex {
+			formatted := formatCellValue(v)
+			if formatted == "" {
+				continue
+			}
+		}
+
+		tier := numTiers // default: unclassified
+		lower := strings.ToLower(k)
+
+		// 1. Exact match on full field name
+		if t, ok := exactMatches[lower]; ok {
+			tier = t
+		} else {
+			// 2. Split camelCase into segments and match the last one
+			segments := splitCamelCase(lower)
+			if len(segments) > 0 {
+				lastSeg := segments[len(segments)-1]
+				if t, ok := suffixMatches[lastSeg]; ok {
+					// Compound names with identity suffixes (BuildingName, TipTime)
+					// get demoted one tier because the prefix dilutes the signal
+					if len(segments) > 1 {
+						tier = t + 1
+					} else {
+						tier = t
+					}
+				}
+			}
+		}
+
+		// Demote booleans to last
+		if _, ok := v.(bool); ok && tier >= numTiers {
+			tier = numTiers + 1
+		}
+		all = append(all, scored{name: k, tier: tier, index: idx})
+		idx++
+	}
+
+	sort.Slice(all, func(i, j int) bool {
+		if all[i].tier != all[j].tier {
+			return all[i].tier < all[j].tier
+		}
+		return all[i].index < all[j].index
+	})
+
+	headers := make([]string, len(all))
+	for i, s := range all {
+		headers[i] = s.name
+	}
+	return headers
+}
+
+// splitCamelCase splits "OrderDate" → ["order", "date"], "statusCode" → ["status", "code"],
+// "page_size" → ["page", "size"].
+func splitCamelCase(s string) []string {
+	var segments []string
+	var current strings.Builder
+	runes := []rune(s)
+	for i, r := range runes {
+		if r == '_' || r == '-' {
+			if current.Len() > 0 {
+				segments = append(segments, current.String())
+				current.Reset()
+			}
+			continue
+		}
+		if i > 0 && unicode.IsUpper(r) && unicode.IsLower(runes[i-1]) {
+			if current.Len() > 0 {
+				segments = append(segments, current.String())
+				current.Reset()
+			}
+		}
+		current.WriteRune(unicode.ToLower(r))
+	}
+	if current.Len() > 0 {
+		segments = append(segments, current.String())
+	}
+	return segments
+}
+
+// printAutoCards renders items as labeled cards — one block per item.
+// Used for complex responses with many fields or nested data.
+func printAutoCards(w io.Writer, items []map[string]any) error {
+	headers := prioritizeAllHeaders(items[0])
+
+	// Find the longest header for alignment (from fields we'll actually show)
+	maxLen := 0
+	for _, h := range headers {
+		if len(h) > maxLen {
+			maxLen = len(h)
+		}
+	}
+
+	for i, item := range items {
+		if i > 0 {
+			fmt.Fprintln(w)
+		}
+
+		// Card header: use first priority field as the card title
+		titleVal := formatCellValue(item[headers[0]])
+		if len(headers) > 1 {
+			secondVal := formatCellValue(item[headers[1]])
+			if secondVal != "" {
+				fmt.Fprintf(w, "%s %s — %s\n", bold(strings.ToUpper(headers[0])), titleVal, secondVal)
+			} else {
+				fmt.Fprintf(w, "%s %s\n", bold(strings.ToUpper(headers[0])), titleVal)
+			}
+		} else {
+			fmt.Fprintf(w, "%s %s\n", bold(strings.ToUpper(headers[0])), titleVal)
+		}
+
+		// Remaining fields indented — skip empty, zero, and false values
+		for _, h := range headers[2:] {
+			v := formatCellValue(item[h])
+			if v == "" || v == "false" || v == "0" || v == "[]" || v == "null" {
+				continue
+			}
+			// Multi-line values (nested arrays) start with \n
+			if strings.HasPrefix(v, "\n") {
+				fmt.Fprintf(w, "  %s:%s\n", h, v)
+			} else {
+				fmt.Fprintf(w, "  %-*s  %s\n", maxLen, h+":", v)
+			}
+		}
+	}
+	return nil
+}
+
+func formatCellValue(v any) string {
+	switch val := v.(type) {
+	case string:
+		// Format ISO dates as just the date portion
+		if len(val) >= 19 && val[4] == '-' && val[7] == '-' && val[10] == 'T' {
+			return val[:10]
+		}
+		return truncate(val, 60)
+	case float64:
+		if val == float64(int64(val)) {
+			return fmt.Sprintf("%d", int64(val))
+		}
+		return fmt.Sprintf("%.2f", val)
+	case bool:
+		return fmt.Sprintf("%t", val)
+	case nil:
+		return ""
+	case []any:
+		if len(val) == 0 {
+			return ""
+		}
+		// If array contains objects, format each as a summary line
+		if obj, isObj := val[0].(map[string]any); isObj {
+			_ = obj
+			return formatObjectArray(val)
+		}
+		// Flatten simple arrays into comma-separated string
+		parts := make([]string, 0, len(val))
+		for _, item := range val {
+			if s, ok := item.(string); ok {
+				parts = append(parts, s)
+			} else {
+				b, _ := json.Marshal(item)
+				parts = append(parts, string(b))
+			}
+		}
+		return truncate(strings.Join(parts, ", "), 60)
+	case map[string]any:
+		return formatSingleObject(val)
+	default:
+		b, _ := json.Marshal(val)
+		return truncate(string(b), 60)
+	}
+}
+
+// formatObjectArray renders an array of objects as multi-line summary.
+// Each object is summarized by its most descriptive fields: name/title, qty, size, price.
+func formatObjectArray(items []any) string {
+	var lines []string
+	for _, raw := range items {
+		obj, ok := raw.(map[string]any)
+		if !ok {
+			continue
+		}
+		lines = append(lines, formatObjectSummary(obj))
+	}
+	if len(lines) == 0 {
+		return ""
+	}
+	// Multi-line: newline-prefixed so the card renderer can indent
+	return "\n" + strings.Join(lines, "\n")
+}
+
+// formatObjectSummary extracts the most useful fields from an object into a one-line summary.
+// Looks for: qty/count → name/title → size → price, in that order.
+func formatObjectSummary(obj map[string]any) string {
+	var parts []string
+
+	// Quantity
+	qty := findField(obj, "qty", "count", "quantity")
+	if qty != "" && qty != "1" && qty != "0" {
+		parts = append(parts, qty+"x")
+	} else if qty == "1" {
+		parts = append(parts, "1x")
+	}
+
+	// Name — check nested objects too (e.g., Side1.Name)
+	name := findField(obj, "name", "title", "label", "description")
+	if name == "" {
+		// Check nested objects for name
+		for _, key := range []string{"Side1", "side1", "Item", "item", "Product", "product"} {
+			if nested, ok := obj[key].(map[string]any); ok {
+				name = findField(nested, "name", "title", "label")
+				if name != "" {
+					break
+				}
+			}
+		}
+	}
+	if name != "" {
+		parts = append(parts, name)
+	}
+
+	// Size
+	size := findField(obj, "sizename", "size_name")
+	if size == "" {
+		size = findField(obj, "catname", "cat_name", "category")
+	}
+	if size != "" {
+		parts = append(parts, "—")
+		parts = append(parts, size)
+	}
+
+	// Price
+	price := findField(obj, "extprice", "price", "amount", "total")
+	if price != "" && price != "0" {
+		parts = append(parts, fmt.Sprintf("($%s)", price))
+	}
+
+	if len(parts) == 0 {
+		// Fallback: JSON summary
+		b, _ := json.Marshal(obj)
+		return truncate(string(b), 80)
+	}
+	return "    " + strings.Join(parts, " ")
+}
+
+// formatSingleObject renders a single object by its most descriptive fields.
+func formatSingleObject(obj map[string]any) string {
+	name := findField(obj, "name", "title", "label", "description")
+	if name != "" {
+		return name
+	}
+	id := findField(obj, "id", "key", "code")
+	if id != "" {
+		return id
+	}
+	return ""
+}
+
+// findField searches an object for a field name (case-insensitive) and returns its formatted value.
+func findField(obj map[string]any, names ...string) string {
+	for _, name := range names {
+		for k, v := range obj {
+			if strings.EqualFold(k, name) {
+				return formatCellValue(v)
+			}
+		}
+	}
+	return ""
+}
+
+// DataProvenance describes where data came from and when it was last synced.
+type DataProvenance struct {
+	Source       string     `json:"source"`                  // "live" or "local"
+	SyncedAt     *time.Time `json:"synced_at,omitempty"`     // when local data was last synced
+	Reason       string     `json:"reason,omitempty"`        // why local was used: "user_requested", "api_unreachable", "no_search_endpoint"
+	ResourceType string     `json:"resource_type,omitempty"` // which resource type was queried
+	Freshness    any        `json:"freshness,omitempty"`     // optional machine-owned freshness metadata for covered command paths
+}
+
+// printProvenance writes a one-line provenance message to stderr for TTY users.
+// Suppressed when stdout is piped or redirected — the JSON response envelope
+// already carries meta.source, so the stderr line is redundant and becomes
+// noise in agent flows that merge stderr into stdout.
+func printProvenance(cmd *cobra.Command, count int, prov DataProvenance) {
+	if !isTerminal(cmd.OutOrStdout()) {
+		return
+	}
+	if prov.Source == "live" {
+		fmt.Fprintf(cmd.ErrOrStderr(), "%d results (live)\n", count)
+		return
+	}
+	age := "unknown"
+	if prov.SyncedAt != nil {
+		d := time.Since(*prov.SyncedAt)
+		switch {
+		case d < time.Minute:
+			age = "just now"
+		case d < time.Hour:
+			age = fmt.Sprintf("%d minutes ago", int(d.Minutes()))
+		case d < 24*time.Hour:
+			age = fmt.Sprintf("%d hours ago", int(d.Hours()))
+		default:
+			age = fmt.Sprintf("%d days ago", int(d.Hours()/24))
+		}
+	}
+	prefix := ""
+	if prov.Reason == "api_unreachable" {
+		prefix = "API unreachable. "
+	}
+	fmt.Fprintf(cmd.ErrOrStderr(), "%s%d results (cached, synced %s)\n", prefix, count, age)
+}
+
+// unwrapSingleKeyArray flattens single-key collection envelopes
+// ({"results":[...]}, {"data":[...]}, etc.) so the agent envelope
+// emits a stable .results[] across APIs. Multi-key objects pass
+// through so cursor/pagination fields stay accessible; non-array
+// values pass through so non-collection responses aren't reshaped.
+//
+// The wrapper-key set is intentionally narrower than
+// extractPaginatedItems (which also walks domain-specific keys like
+// "messages", "members", "values" used by social/messaging APIs).
+// This helper only flattens canonical collection envelopes for
+// --json output; the pagination walker has a broader remit.
+func unwrapSingleKeyArray(data json.RawMessage) json.RawMessage {
+	leading := bytes.TrimLeft(data, " \t\r\n")
+	if len(leading) == 0 || leading[0] != '{' {
+		return data
+	}
+	var obj map[string]json.RawMessage
+	if err := json.Unmarshal(data, &obj); err != nil {
+		return data
+	}
+	if len(obj) != 1 {
+		return data
+	}
+	for key, val := range obj {
+		if key != "results" && key != "data" && key != "items" && key != "nodes" && key != "entries" && key != "records" {
+			return data
+		}
+		trimmed := bytes.TrimLeft(val, " \t\r\n")
+		if len(trimmed) == 0 || trimmed[0] != '[' {
+			return data
+		}
+		return val
+	}
+	return data
+}
+
+// wrapWithProvenance wraps response data in a provenance envelope:
+// {"results": ..., "meta": {...}}. When data is valid JSON, it embeds as
+// the parsed shape; when data is non-JSON (e.g., XML/RSS responses, plain
+// text), it embeds as a JSON string so json.Marshal doesn't choke on
+// "invalid character '<'" while still passing the raw payload through to
+// the consumer. Single-key array envelopes from the API (e.g.
+// {"results": [...]}, {"data": [...]}) are unwrapped first so the output
+// shape is the same regardless of the API's wrapper key.
+func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMessage, error) {
+	meta := map[string]any{"source": prov.Source}
+	if prov.SyncedAt != nil {
+		meta["synced_at"] = prov.SyncedAt.UTC().Format(time.RFC3339)
+	}
+	if prov.Reason != "" {
+		meta["reason"] = prov.Reason
+	}
+	if prov.ResourceType != "" {
+		meta["resource_type"] = prov.ResourceType
+	}
+	if prov.Freshness != nil {
+		meta["freshness"] = prov.Freshness
+	}
+	var results any
+	if json.Valid(data) {
+		results = json.RawMessage(unwrapSingleKeyArray(data))
+	} else {
+		results = string(data)
+	}
+	envelope := map[string]any{
+		"results": results,
+		"meta":    meta,
+	}
+	return json.Marshal(envelope)
+}
+
+// defaultDBPath returns the canonical path for the local SQLite database.
+func defaultDBPath(name string) string {
+	home, _ := os.UserHomeDir()
+	return filepath.Join(home, ".local", "share", name, "data.db")
+}
diff --git a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/pages_get.go b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/pages_get.go
new file mode 100644
index 00000000..b4c93df1
--- /dev/null
+++ b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/pages_get.go
@@ -0,0 +1,100 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+)
+
+func newPagesGetCmd(flags *rootFlags) *cobra.Command {
+
+	cmd := &cobra.Command{
+		Use:         "get <id>",
+		Short:       "Get",
+		Example:     "  embedded-paged-pp-cli pages get 550e8400-e29b-41d4-a716-446655440000",
+		Annotations: map[string]string{"pp:endpoint": "pages.get", "pp:method": "GET", "pp:path": "/pages/{id}", "mcp:read-only": "true"},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if len(args) == 0 {
+				return cmd.Help()
+			}
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			path := "/pages/{id}"
+			path = replacePathParam(path, "id", args[0])
+			params := map[string]string{}
+			data, prov, err := resolveRead(cmd.Context(), c, flags, "pages", false, path, params, nil)
+			if err != nil {
+				return classifyAPIError(err, flags)
+			}
+			// Print provenance to stderr for human-facing output only.
+			// Machine-format flags (--json, --csv, --compact, --quiet, --plain,
+			// --select) and piped stdout suppress this line; the JSON envelope
+			// already carries meta.source for those consumers.
+			// SYNC: keep this gate aligned with command_promoted.go.tmpl.
+			if wantsHumanTable(cmd.OutOrStdout(), flags) {
+				var countItems []json.RawMessage
+				_ = json.Unmarshal(data, &countItems)
+				printProvenance(cmd, len(countItems), prov)
+			}
+			// For JSON output, wrap with provenance envelope before passing through flags.
+			// --select wins over --compact when both are set; --compact only runs when
+			// no explicit fields were requested. Explicit format flags (--csv, --quiet,
+			// --plain) opt out of the auto-JSON path so piped consumers that asked for
+			// a non-JSON format reach the standard pipeline below.
+			if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
+				filtered := data
+				if flags.selectFields != "" {
+					filtered = filterFields(filtered, flags.selectFields)
+				} else if flags.compact {
+					filtered = compactFields(filtered)
+				}
+				wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+				if wrapErr != nil {
+					return wrapErr
+				}
+				return printOutput(cmd.OutOrStdout(), wrapped, true)
+			}
+			// For all other output modes (table, csv, plain, quiet), use the standard pipeline
+			if wantsHumanTable(cmd.OutOrStdout(), flags) {
+				var items []map[string]any
+				if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+					if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+						return err
+					}
+					if len(items) >= 25 {
+						fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+					}
+					return nil
+				}
+			}
+			return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+		},
+	}
+
+	return cmd
+}
+
+// fetchFullPagesGetChildren returns every item
+// in the "children" sub-resource of GET /pages/{id}. The parent
+// response embeds at most the first page of this sub-resource, so any
+// caller that treats the embed as complete silently truncates; this
+// companion calls the dedicated child endpoint and paginates until
+// exhausted. pathParams must include every {placeholder} in the parent
+// path (e.g. {"id": "<value>"}).
+func fetchFullPagesGetChildren(c interface {
+	GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, pathParams map[string]string) ([]json.RawMessage, error) {
+	childPath := "/pages/{id}/children"
+	for name, val := range pathParams {
+		childPath = replacePathParam(childPath, name, val)
+	}
+	return fetchEmbeddedPagedSubresource(c, childPath, "next_cursor", false, false)
+}
diff --git a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/playlists_get.go b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/playlists_get.go
new file mode 100644
index 00000000..18949de7
--- /dev/null
+++ b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/playlists_get.go
@@ -0,0 +1,100 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+)
+
+func newPlaylistsGetCmd(flags *rootFlags) *cobra.Command {
+
+	cmd := &cobra.Command{
+		Use:         "get <id>",
+		Short:       "Get",
+		Example:     "  embedded-paged-pp-cli playlists get 550e8400-e29b-41d4-a716-446655440000",
+		Annotations: map[string]string{"pp:endpoint": "playlists.get", "pp:method": "GET", "pp:path": "/playlists/{id}", "mcp:read-only": "true"},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if len(args) == 0 {
+				return cmd.Help()
+			}
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			path := "/playlists/{id}"
+			path = replacePathParam(path, "id", args[0])
+			params := map[string]string{}
+			data, prov, err := resolveRead(cmd.Context(), c, flags, "playlists", false, path, params, nil)
+			if err != nil {
+				return classifyAPIError(err, flags)
+			}
+			// Print provenance to stderr for human-facing output only.
+			// Machine-format flags (--json, --csv, --compact, --quiet, --plain,
+			// --select) and piped stdout suppress this line; the JSON envelope
+			// already carries meta.source for those consumers.
+			// SYNC: keep this gate aligned with command_promoted.go.tmpl.
+			if wantsHumanTable(cmd.OutOrStdout(), flags) {
+				var countItems []json.RawMessage
+				_ = json.Unmarshal(data, &countItems)
+				printProvenance(cmd, len(countItems), prov)
+			}
+			// For JSON output, wrap with provenance envelope before passing through flags.
+			// --select wins over --compact when both are set; --compact only runs when
+			// no explicit fields were requested. Explicit format flags (--csv, --quiet,
+			// --plain) opt out of the auto-JSON path so piped consumers that asked for
+			// a non-JSON format reach the standard pipeline below.
+			if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
+				filtered := data
+				if flags.selectFields != "" {
+					filtered = filterFields(filtered, flags.selectFields)
+				} else if flags.compact {
+					filtered = compactFields(filtered)
+				}
+				wrapped, wrapErr := wrapWithProvenance(filtered, prov)
+				if wrapErr != nil {
+					return wrapErr
+				}
+				return printOutput(cmd.OutOrStdout(), wrapped, true)
+			}
+			// For all other output modes (table, csv, plain, quiet), use the standard pipeline
+			if wantsHumanTable(cmd.OutOrStdout(), flags) {
+				var items []map[string]any
+				if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+					if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+						return err
+					}
+					if len(items) >= 25 {
+						fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+					}
+					return nil
+				}
+			}
+			return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+		},
+	}
+
+	return cmd
+}
+
+// fetchFullPlaylistsGetTracks returns every item
+// in the "tracks" sub-resource of GET /playlists/{id}. The parent
+// response embeds at most the first page of this sub-resource, so any
+// caller that treats the embed as complete silently truncates; this
+// companion calls the dedicated child endpoint and paginates until
+// exhausted. pathParams must include every {placeholder} in the parent
+// path (e.g. {"id": "<value>"}).
+func fetchFullPlaylistsGetTracks(c interface {
+	GetWithHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, error)
+}, pathParams map[string]string) ([]json.RawMessage, error) {
+	childPath := "/playlists/{id}/tracks"
+	for name, val := range pathParams {
+		childPath = replacePathParam(childPath, name, val)
+	}
+	return fetchEmbeddedPagedSubresource(c, childPath, "next", true, false)
+}
diff --git a/testdata/golden/expected/generate-embedded-paged-api/exit.txt b/testdata/golden/expected/generate-embedded-paged-api/exit.txt
new file mode 100644
index 00000000..573541ac
--- /dev/null
+++ b/testdata/golden/expected/generate-embedded-paged-api/exit.txt
@@ -0,0 +1 @@
+0
diff --git a/testdata/golden/expected/generate-embedded-paged-api/stderr.txt b/testdata/golden/expected/generate-embedded-paged-api/stderr.txt
new file mode 100644
index 00000000..a2af0a52
--- /dev/null
+++ b/testdata/golden/expected/generate-embedded-paged-api/stderr.txt
@@ -0,0 +1,2 @@
+warning: could not derive run_id from --research-dir; phase5 dogfood acceptance will refuse to write without it
+Generated embedded-paged at <ARTIFACT_DIR>/generate-embedded-paged-api/embedded-paged-api
diff --git a/testdata/golden/expected/generate-embedded-paged-api/stdout.txt b/testdata/golden/expected/generate-embedded-paged-api/stdout.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/golden/fixtures/embedded-paged-api.yaml b/testdata/golden/fixtures/embedded-paged-api.yaml
new file mode 100644
index 00000000..324ef798
--- /dev/null
+++ b/testdata/golden/fixtures/embedded-paged-api.yaml
@@ -0,0 +1,137 @@
+openapi: "3.0.3"
+info:
+  title: Embedded Paged API
+  version: "1.0.0"
+  description: |
+    Golden fixture exercising the embedded-paged-sub-resource detection
+    and emission path. The parent GET responses embed three envelope
+    shapes the detector must distinguish: a URL-bearing `next`, an
+    opaque cursor `next_cursor`, and a boolean `has_more`. Generated
+    output should contain a `fetchFull<X>` wrapper per detected entry
+    plus the shared `fetchEmbeddedPagedSubresource` runtime helper.
+servers:
+  - url: https://api.embedded.example/v1
+security:
+  - ApiKeyAuth: []
+components:
+  securitySchemes:
+    ApiKeyAuth:
+      type: apiKey
+      in: header
+      name: X-API-Key
+  schemas:
+    Track:
+      type: object
+      properties:
+        id: { type: string }
+        title: { type: string }
+    Playlist:
+      type: object
+      properties:
+        id: { type: string }
+        name: { type: string }
+        tracks:
+          type: object
+          properties:
+            items:
+              type: array
+              items: { $ref: "#/components/schemas/Track" }
+            next: { type: string }
+            total: { type: integer }
+    Page:
+      type: object
+      properties:
+        id: { type: string }
+        title: { type: string }
+        children:
+          type: object
+          properties:
+            results:
+              type: array
+              items: { type: object }
+            next_cursor: { type: string }
+    Customer:
+      type: object
+      properties:
+        id: { type: string }
+        email: { type: string }
+        subscriptions:
+          type: object
+          properties:
+            data:
+              type: array
+              items: { type: object }
+            has_more: { type: boolean }
+paths:
+  /playlists:
+    get:
+      tags: [playlists]
+      operationId: listPlaylists
+      responses:
+        "200":
+          description: OK
+          content:
+            application/json:
+              schema:
+                type: array
+                items: { $ref: "#/components/schemas/Playlist" }
+  /playlists/{id}:
+    get:
+      tags: [playlists]
+      operationId: getPlaylist
+      parameters:
+        - { name: id, in: path, required: true, schema: { type: string } }
+      responses:
+        "200":
+          description: OK
+          content:
+            application/json:
+              schema: { $ref: "#/components/schemas/Playlist" }
+  /pages:
+    get:
+      tags: [pages]
+      operationId: listPages
+      responses:
+        "200":
+          description: OK
+          content:
+            application/json:
+              schema:
+                type: array
+                items: { $ref: "#/components/schemas/Page" }
+  /pages/{id}:
+    get:
+      tags: [pages]
+      operationId: getPage
+      parameters:
+        - { name: id, in: path, required: true, schema: { type: string } }
+      responses:
+        "200":
+          description: OK
+          content:
+            application/json:
+              schema: { $ref: "#/components/schemas/Page" }
+  /customers:
+    get:
+      tags: [customers]
+      operationId: listCustomers
+      responses:
+        "200":
+          description: OK
+          content:
+            application/json:
+              schema:
+                type: array
+                items: { $ref: "#/components/schemas/Customer" }
+  /customers/{id}:
+    get:
+      tags: [customers]
+      operationId: getCustomer
+      parameters:
+        - { name: id, in: path, required: true, schema: { type: string } }
+      responses:
+        "200":
+          description: OK
+          content:
+            application/json:
+              schema: { $ref: "#/components/schemas/Customer" }

← e21feae6 fix(cli): close ResolveByName json_extract injection + dedup  ·  back to Cli Printing Press  ·  fix(cli): support $-prefixed pagination params for Socrata-s 3819b67f →