[object Object]

← back to Cli Printing Press

feat(cli): add proxy-envelope client pattern to generator (#65)

f0bb0de244f7e61d43523430b8641ad7180bf4a3 · 2026-03-29 21:57:44 -0700 · Trevin Chow

When --client-pattern proxy-envelope is passed to `printing-press generate`,
the generated client wraps all requests in a POST envelope with {service,
method, path, body?} instead of standard REST URL construction. This
eliminates the single most expensive manual customization for sniffed APIs.

- Add ClientPattern and ProxyRoutes fields to spec.APISpec
- Extract x-proxy-routes from OpenAPI info.extensions in the parser
- Add --client-pattern flag (rest, proxy-envelope) to generate command
- Template conditional in client.go.tmpl emits proxy-wrapping do() method
  with serviceForPath() routing and buildProxyPath() helper
- Shared retry loop, rate limiter, cache, and error handling across both
  REST and proxy-envelope paths
- Add proxy pattern detection heuristic to SKILL.md sniff gate

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit f0bb0de244f7e61d43523430b8641ad7180bf4a3
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun Mar 29 21:57:44 2026 -0700

    feat(cli): add proxy-envelope client pattern to generator (#65)
    
    When --client-pattern proxy-envelope is passed to `printing-press generate`,
    the generated client wraps all requests in a POST envelope with {service,
    method, path, body?} instead of standard REST URL construction. This
    eliminates the single most expensive manual customization for sniffed APIs.
    
    - Add ClientPattern and ProxyRoutes fields to spec.APISpec
    - Extract x-proxy-routes from OpenAPI info.extensions in the parser
    - Add --client-pattern flag (rest, proxy-envelope) to generate command
    - Template conditional in client.go.tmpl emits proxy-wrapping do() method
      with serviceForPath() routing and buildProxyPath() helper
    - Shared retry loop, rate limiter, cache, and error handling across both
      REST and proxy-envelope paths
    - Add proxy pattern detection heuristic to SKILL.md sniff gate
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 ...9-client-pattern-proxy-envelope-requirements.md |  74 ++++++
 ...-003-feat-proxy-envelope-client-pattern-plan.md | 252 +++++++++++++++++++++
 internal/cli/root.go                               |  18 ++
 internal/generator/templates/client.go.tmpl        | 146 +++++++++---
 internal/openapi/parser.go                         |  16 ++
 internal/spec/spec.go                              |  24 +-
 skills/printing-press/SKILL.md                     |  24 ++
 7 files changed, 516 insertions(+), 38 deletions(-)

diff --git a/docs/brainstorms/2026-03-29-client-pattern-proxy-envelope-requirements.md b/docs/brainstorms/2026-03-29-client-pattern-proxy-envelope-requirements.md
new file mode 100644
index 00000000..c9fdc80d
--- /dev/null
+++ b/docs/brainstorms/2026-03-29-client-pattern-proxy-envelope-requirements.md
@@ -0,0 +1,74 @@
+---
+date: 2026-03-29
+topic: client-pattern-proxy-envelope
+---
+
+# Generator Support for Proxy-Envelope Client Pattern
+
+## Problem Frame
+
+When generating CLIs for sniffed APIs that use a proxy pattern (all requests POST'd to a single URL with a `{service, method, path, body?}` envelope), the generator produces a standard REST client that doesn't work. The skill must then manually rewrite `do()` in the generated `client.go` — a ~50-line change that must preserve rate limiter integration, retry logic, and caching. This customization had to be re-applied 3 times during the postman-explore-pp-cli build (once per regeneration cycle), making it the single most expensive post-generation fix.
+
+The `client_pattern` field already exists in the catalog schema (PR #61) with `proxy-envelope` as a valid value, but the generator ignores it completely.
+
+## Requirements
+
+**Generator Template Selection**
+
+- R1. When `spec.APISpec.ClientPattern` is `"proxy-envelope"`, the generator emits a proxy-wrapping `do()` method in `client.go` instead of the standard REST `do()` method
+- R2. The proxy `do()` always POST's to `c.BaseURL` with a JSON envelope containing `service`, `method` (the logical HTTP method), `path` (with query params inlined), and `body` (for POST/PUT/PATCH requests)
+- R3. All existing client features work unchanged with the proxy pattern: adaptive rate limiter, retry-on-429, exponential backoff on 5xx, caching for GET, dry-run display
+- R4. Auth methods (`authHeader`, `refreshAccessToken`) are still emitted when the spec defines auth. Proxy-envelope does not imply no-auth — that's a separate concern controlled by the spec's `auth` section
+
+**Service Routing**
+
+- R5. Default: all paths route to a single service named after the API slug (e.g., `"postman-explore"`)
+- R6. Override: if the spec contains an `x-proxy-routes` extension (a map of path prefixes to service names), the generated client uses it for routing
+- R7. The `x-proxy-routes` extension is optional. When absent, the single-service default applies
+
+**Plumbing**
+
+- R8. Add `ClientPattern string` field to `spec.APISpec` (alongside the existing `SpecSource`)
+- R9. The generate CLI command sets `ClientPattern` from a new `--client-pattern` flag or from the catalog entry when generating from a catalog API
+- R10. The `client.go.tmpl` template uses `{{if eq .ClientPattern "proxy-envelope"}}` conditionals around the `do()` method and proxy-specific types
+
+**Skill Integration**
+
+- R11. During the sniff phase, when the skill discovers a proxy pattern (repeated calls to a single URL with envelope bodies), it writes `x-proxy-routes` into the generated spec
+- R12. The skill's Phase 2 generate commands for sniff-based CLIs include `--client-pattern proxy-envelope`
+
+## Success Criteria
+
+- A `printing-press generate --spec <sniffed-spec> --client-pattern proxy-envelope` produces a CLI that works against the proxy API without any manual client.go modifications
+- Regenerating the postman-explore-pp-cli with the proxy spec and `--client-pattern proxy-envelope` produces a working CLI where only UX customizations (top-level aliases, store tables) are needed — the client itself works out of the box
+- Standard REST APIs are unaffected — `client_pattern` empty or `"rest"` produces the same `client.go` as today
+
+## Scope Boundaries
+
+- Proxy-envelope pattern only — no GraphQL client pattern in this work
+- No changes to the catalog schema (already has `client_pattern`)
+- No envelope shape configurability — the `{service, method, path, body?}` shape is hardcoded. If a future proxy API uses a different envelope shape, that's a separate enhancement
+- The skill's sniff phase improvements (auto-detecting proxy patterns, writing x-proxy-routes) are included but lightweight — the skill already knows how to identify proxy patterns from the postman-explore experience
+
+## Key Decisions
+
+- **Convention + override for service routing**: Default single-service (API slug) covers most cases. `x-proxy-routes` handles multi-service APIs like Postman. No catalog schema changes needed.
+- **Template conditional, not separate template file**: One `client.go.tmpl` with `{{if eq .ClientPattern "proxy-envelope"}}` blocks. The proxy `do()` is different enough to warrant separate blocks but similar enough (same retry/cache/rate-limit integration) that a completely separate template would duplicate too much.
+- **Proxy URL comes from existing `servers` field**: No new field needed. The spec's server URL is the proxy endpoint.
+
+## Dependencies / Assumptions
+
+- `spec.APISpec` already has `SpecSource` (PR #62). `ClientPattern` follows the same pattern.
+- The catalog schema already has `client_pattern` with `proxy-envelope` as a valid value (PR #61).
+- The `x-proxy-routes` OpenAPI extension is a custom extension — it's our convention, not a standard. That's fine for a generator-specific feature.
+
+## Outstanding Questions
+
+### Deferred to Planning
+- [Affects R10][Technical] How much of the proxy `do()` can share code with the REST `do()`? The retry loop, rate limiter, and cache are identical — only the request building differs. The planner should determine whether to extract a shared `executeWithRetry()` helper or duplicate the loop with different request construction.
+- [Affects R6][Needs research] What does `x-proxy-routes` look like in YAML? The planner should design the extension schema (e.g., `x-proxy-routes: {"/v1/api/": "publishing", "/search-all": "search"}`).
+- [Affects R11][Needs research] How does the skill detect the proxy pattern during sniffing? Currently it's manual observation ("all calls go to _api/ws/proxy"). Can we formalize this as a heuristic (e.g., all captured URLs resolve to the same path)?
+
+## Next Steps
+
+→ `/ce:plan` for structured implementation planning
diff --git a/docs/plans/2026-03-29-003-feat-proxy-envelope-client-pattern-plan.md b/docs/plans/2026-03-29-003-feat-proxy-envelope-client-pattern-plan.md
new file mode 100644
index 00000000..2bdffe06
--- /dev/null
+++ b/docs/plans/2026-03-29-003-feat-proxy-envelope-client-pattern-plan.md
@@ -0,0 +1,252 @@
+---
+title: "feat: Generator support for proxy-envelope client pattern"
+type: feat
+status: active
+date: 2026-03-29
+origin: docs/brainstorms/2026-03-29-client-pattern-proxy-envelope-requirements.md
+---
+
+# feat: Generator Support for Proxy-Envelope Client Pattern
+
+## Overview
+
+Teach the printing-press generator to emit a proxy-wrapping HTTP client when `client_pattern: proxy-envelope` is set. Instead of building `base_url + path` URLs, the generated client wraps all requests as `POST base_url` with a `{service, method, path, body?}` JSON envelope. This eliminates the single most expensive manual customization needed for sniffed APIs.
+
+## Problem Frame
+
+During the postman-explore-pp-cli build, the proxy client wrapper had to be re-applied 3 times across regeneration cycles — each time carefully preserving rate limiter integration, retry logic, and caching. The `client_pattern` field exists in the catalog schema (PR #61) but the generator ignores it. (see origin: docs/brainstorms/2026-03-29-client-pattern-proxy-envelope-requirements.md)
+
+## Requirements Trace
+
+- R1. Proxy-wrapping `do()` when `ClientPattern == "proxy-envelope"`
+- R2. Envelope: `{service, method, path, body?}` POST'd to `BaseURL`
+- R3. Rate limiter, retry, cache, dry-run unchanged
+- R4. Auth still emitted when spec defines it
+- R5-R7. Service routing: default single-service (API slug), override via `x-proxy-routes`
+- R8-R9. `ClientPattern` on APISpec, set via `--client-pattern` flag
+- R10. Template conditional in `client.go.tmpl`
+- R11-R12. Skill integration for sniff detection and generate flags
+
+## Scope Boundaries
+
+- Proxy-envelope only — no GraphQL pattern
+- No envelope shape configurability — `{service, method, path, body?}` is hardcoded
+- No catalog schema changes (already has `client_pattern`)
+- Skill sniff detection is heuristic guidance, not automated detection
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/generator/templates/client.go.tmpl` — The `do()` method has clearly separable sections: request construction (lines 231-268) is independent from the retry loop (lines 224-318). The proxy conditional wraps only the request construction, sharing all retry/cache/rate-limiter logic.
+- `internal/openapi/parser.go:140-148` — Existing pattern for extracting OpenAPI extensions (`x-api-name` from `doc.Info.Extensions`). The `x-proxy-routes` extension follows this exact pattern.
+- `internal/spec/spec.go` — `SpecSource` field was added in PR #62 following the same pattern `ClientPattern` will follow.
+- `internal/generator/generator.go:123` — Templates receive `*spec.APISpec` directly via `renderTemplate(tmplName, outPath, g.Spec)`. New fields on APISpec are automatically accessible in templates.
+- `internal/cli/root.go:322` — `--spec-source` flag pattern to follow for `--client-pattern`.
+
+### Institutional Learnings
+
+- `docs/solutions/best-practices/adaptive-rate-limiting-sniffed-apis.md` — Documents the adaptive rate limiter that the proxy client must preserve. The learning confirms that the proxy pattern requires the rate limiter integration to stay intact.
+
+## Key Technical Decisions
+
+- **Template conditional, not separate template file**: The proxy `do()` differs only in request construction (~15 lines). The retry loop, rate limiter calls, response handling, and error classification are identical. A `{{if eq .ClientPattern "proxy-envelope"}}` block wrapping the request construction section avoids duplicating ~100 lines of shared logic. (see origin)
+- **Shared retry loop with strategy injection**: Rather than duplicating the entire `do()` method, the template emits a `buildRequest()` helper that returns `*http.Request`. The `do()` loop calls `buildRequest()` then proceeds with shared retry logic. The conditional controls which `buildRequest()` is emitted.
+- **x-proxy-routes in spec info.extensions**: Follows the existing `x-api-name` pattern. Parsed during OpenAPI loading, stored on APISpec as a `map[string]string`. When absent, a default route maps all paths to the API name slug.
+- **`--client-pattern` flag validated same as `--spec-source`**: Allowed values: `rest`, `proxy-envelope`. Invalid values rejected with a clear error message.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **How much of `do()` can be shared?** The request construction section (lines 231-268) is the only divergent part. The template emits a `buildRequest()` helper selected by `ClientPattern`. The rest of `do()` is shared.
+- **x-proxy-routes schema:** `info.extensions["x-proxy-routes"]` as a `map[string]string` mapping path prefixes to service names. Parsed in `openapi/parser.go` alongside `x-api-name`. Stored on APISpec as `ProxyRoutes map[string]string`.
+- **Proxy pattern detection heuristic:** During sniff, if all captured Performance API URLs resolve to the same path AND intercepted fetch bodies contain `{service, method, path}` keys, it's a proxy-envelope. This is behavioral guidance in SKILL.md, not automated detection.
+
+### Deferred to Implementation
+
+- Exact placement of the `buildRequest()` helper in client.go.tmpl — depends on reading the current template structure at implementation time
+- Whether `x-proxy-routes` needs path prefix matching (longest match) or exact match — start with prefix match, simplify if it turns out to be unnecessary
+
+## High-Level Technical Design
+
+> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
+
+```
+client.go.tmpl do() method structure:
+
+  func (c *Client) do(method, path, params, body) {
+    url = c.BaseURL + path          // ← REST path
+    // OR
+    url = c.BaseURL                 // ← proxy-envelope (always same URL)
+
+    bodyBytes = marshal(body)
+
+    if c.DryRun { return dryRun(...) }
+
+    for attempt := 0..maxRetries {
+      c.limiter.Wait()               // shared
+
+      req = buildRequest(...)        // ← CONDITIONAL: REST vs proxy-envelope
+
+      resp = c.HTTPClient.Do(req)    // shared
+
+      if resp < 400 {                // shared
+        c.limiter.OnSuccess()
+        return resp
+      }
+      if resp == 429 {               // shared
+        c.limiter.OnRateLimit()
+        sleep(retryAfter)
+      }
+      if resp >= 500 { ... }         // shared
+    }
+  }
+
+  // REST buildRequest: standard HTTP method + URL + params + body
+  // Proxy buildRequest: POST + BaseURL + envelope{service, method, path+params, body}
+```
+
+## Implementation Units
+
+- [ ] **Unit 1: Add ClientPattern and ProxyRoutes to APISpec**
+
+**Goal:** Make `client_pattern` and `x-proxy-routes` available to templates.
+
+**Requirements:** R8
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/spec/spec.go`
+- Modify: `internal/openapi/parser.go`
+- Test: `internal/spec/spec_test.go`
+- Test: `internal/openapi/parser_test.go`
+
+**Approach:**
+- Add `ClientPattern string` and `ProxyRoutes map[string]string` to `APISpec`
+- In `openapi/parser.go`, extract `x-proxy-routes` from `doc.Info.Extensions` (follow the `x-api-name` pattern at line 140)
+- ProxyRoutes is a simple `map[string]string` where keys are path prefixes and values are service names
+
+**Patterns to follow:**
+- `SpecSource` field addition in PR #62
+- `x-api-name` extension extraction in `openapi/parser.go:140-148`
+
+**Test scenarios:**
+- Happy path: Spec with `x-proxy-routes: {"/v1/api/": "publishing", "/search-all": "search"}` → parsed APISpec has ProxyRoutes map with both entries
+- Happy path: Spec without `x-proxy-routes` → ProxyRoutes is nil/empty
+- Edge case: `x-proxy-routes` with wrong type (not a map) → gracefully ignored, ProxyRoutes stays nil
+
+**Verification:**
+- `go test ./internal/spec/... ./internal/openapi/...` passes
+- Template can access `{{.ClientPattern}}` and `{{.ProxyRoutes}}`
+
+- [ ] **Unit 2: Add --client-pattern flag to generate command**
+
+**Goal:** Users can set `ClientPattern` via CLI flag.
+
+**Requirements:** R9
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Modify: `internal/cli/root.go`
+- Test: `internal/cli/root_test.go` (if exists)
+
+**Approach:**
+- Add `--client-pattern` flag (string, default empty)
+- Validate against `rest`, `proxy-envelope` (same pattern as `--spec-source` validation)
+- Set `apiSpec.ClientPattern` after parsing, in both the `--docs` and `--spec` paths
+- Empty string means "rest" (default behavior)
+
+**Patterns to follow:**
+- `--spec-source` flag at line 322 and validation at lines 226-233
+
+**Test scenarios:**
+- Happy path: `--client-pattern proxy-envelope` → apiSpec.ClientPattern set to "proxy-envelope"
+- Happy path: No flag → ClientPattern empty (REST default)
+- Error path: `--client-pattern soap` → exits with validation error naming allowed values
+
+**Verification:**
+- `printing-press generate --help` shows `--client-pattern` flag
+- Invalid values rejected with clear error
+
+- [ ] **Unit 3: Emit proxy-envelope client in client.go.tmpl**
+
+**Goal:** The template emits a proxy-wrapping `do()` method when `ClientPattern == "proxy-envelope"`.
+
+**Requirements:** R1, R2, R3, R4, R5, R6, R7, R10
+
+**Dependencies:** Unit 1, Unit 2
+
+**Files:**
+- Modify: `internal/generator/templates/client.go.tmpl`
+- Test: `internal/generator/generator_test.go`
+
+**Approach:**
+- Add `proxyEnvelope` struct and `serviceForPath()` helper inside a `{{if eq .ClientPattern "proxy-envelope"}}` block
+- `serviceForPath()` checks `ProxyRoutes` map (longest prefix match), falls back to API name slug
+- Replace the request construction section of `do()` with a conditional: proxy-envelope wraps in envelope and POST's to `BaseURL`; REST builds standard HTTP request (existing behavior)
+- Both paths share the retry loop, rate limiter, cache, and response handling
+- Dry-run output shows the envelope for proxy-envelope mode
+- MCP tools template (`mcp_tools.go.tmpl`) also needs the conditional for `newMCPClient()`
+
+**Patterns to follow:**
+- Existing `{{if eq .SpecSource "sniffed"}}` conditionals in `root.go.tmpl` and `mcp_tools.go.tmpl`
+- The hand-built proxy client in postman-explore-pp-cli's `client.go` (the proven implementation)
+
+**Test scenarios:**
+- Happy path: Generate from spec with `ClientPattern = "proxy-envelope"` → client.go contains `proxyEnvelope` struct and `serviceForPath()` function
+- Happy path: Generate from spec with `ClientPattern = ""` → client.go is identical to today's output (no proxy code)
+- Happy path: ProxyRoutes with 2 entries → `serviceForPath()` routes correctly by prefix
+- Edge case: Empty ProxyRoutes → `serviceForPath()` returns API name slug for all paths
+- Integration: Generated proxy-envelope CLI compiles and passes `go vet`
+
+**Verification:**
+- `go test ./internal/generator/...` passes (compilation tests verify generated code compiles)
+- A generated CLI with `--client-pattern proxy-envelope` has a working proxy `do()` method
+
+- [ ] **Unit 4: Update SKILL.md with proxy pattern detection and generate flags**
+
+**Goal:** The skill knows how to detect proxy patterns during sniffing and passes the right flags to generate.
+
+**Requirements:** R11, R12
+
+**Dependencies:** None (parallel with Units 1-3)
+
+**Files:**
+- Modify: `skills/printing-press/SKILL.md`
+
+**Approach:**
+- In the sniff gate section, add a "Proxy Pattern Detection" heuristic: if all captured XHR/fetch URLs resolve to the same path AND intercepted bodies contain `{service, method, path}` keys, flag it as proxy-envelope
+- When a proxy pattern is detected, the skill should: (a) write `x-proxy-routes` into the spec if multiple services are discovered, (b) pass `--client-pattern proxy-envelope` to the generate command
+- Update Phase 2 generate commands for sniff-based CLIs to include `--client-pattern proxy-envelope` when detected
+
+**Test scenarios:**
+Test expectation: none — skill instruction changes are behavioral guidance for Claude, not compiled code.
+
+**Verification:**
+- SKILL.md contains proxy pattern detection heuristic in sniff gate section
+- Phase 2 generate commands include `--client-pattern proxy-envelope` when appropriate
+
+## System-Wide Impact
+
+- **Template backward compatibility:** Empty `ClientPattern` produces identical output to today. No existing CLIs are affected.
+- **Generated CLI dependencies:** No new dependencies. The proxy envelope code uses stdlib only (same as the hand-built version).
+- **Catalog integration:** The catalog already has `client_pattern`. When the generate command learns to read from catalog entries, proxy-envelope will automatically propagate. This is a future enhancement, not required now.
+- **MCP server parity:** The `mcp_tools.go.tmpl` also constructs a client. It needs the same conditional to work with proxy-envelope APIs.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Template conditional makes client.go.tmpl harder to read | The conditional wraps only the request construction section (~15 lines per branch), not the entire 100-line do() method. Code review will catch readability issues. |
+| Envelope shape assumption (`{service, method, path, body?}`) is too rigid | Scoped as a known limitation. The hardcoded shape matches the only proxy pattern we've encountered. Configurability is a future enhancement if needed. |
+| `x-proxy-routes` prefix matching gets complex | Start with simple iteration over the map. Longest-prefix-match is a well-understood algorithm. If the map has <10 entries (typical), performance is irrelevant. |
+
+## Sources & References
+
+- **Origin document:** [docs/brainstorms/2026-03-29-client-pattern-proxy-envelope-requirements.md](docs/brainstorms/2026-03-29-client-pattern-proxy-envelope-requirements.md)
+- Related code: `internal/generator/templates/client.go.tmpl` (do() method), `internal/openapi/parser.go` (x-api-name pattern)
+- Related PRs: #61 (catalog schema with client_pattern), #62 (adaptive rate limiter with SpecSource)
+- Related learning: `docs/solutions/best-practices/adaptive-rate-limiting-sniffed-apis.md`
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 2974e316..01699954 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -66,6 +66,7 @@ func newGenerateCmd() *cobra.Command {
 	var asJSON bool
 	var dryRun bool
 	var specSource string
+	var clientPattern string
 
 	cmd := &cobra.Command{
 		Use:   "generate",
@@ -126,6 +127,14 @@ func newGenerateCmd() *cobra.Command {
 				} else {
 					parsed.SpecSource = "docs"
 				}
+				if clientPattern != "" {
+					switch clientPattern {
+					case "rest", "proxy-envelope":
+						parsed.ClientPattern = clientPattern
+					default:
+						return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--client-pattern must be one of: rest, proxy-envelope (got %q)", clientPattern)}
+					}
+				}
 
 				explicitOutput := outputDir != ""
 				if outputDir == "" {
@@ -231,6 +240,14 @@ func newGenerateCmd() *cobra.Command {
 					return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--spec-source must be one of: official, community, sniffed, docs (got %q)", specSource)}
 				}
 			}
+			if clientPattern != "" {
+				switch clientPattern {
+				case "rest", "proxy-envelope":
+					apiSpec.ClientPattern = clientPattern
+				default:
+					return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--client-pattern must be one of: rest, proxy-envelope (got %q)", clientPattern)}
+				}
+			}
 
 			explicitOutput := outputDir != ""
 			if outputDir == "" {
@@ -320,6 +337,7 @@ func newGenerateCmd() *cobra.Command {
 	cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
 	cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Parse spec and show what would be generated without writing files (remote specs are still fetched)")
 	cmd.Flags().StringVar(&specSource, "spec-source", "", "Spec provenance: official, community, sniffed, docs (affects generated client defaults like rate limiting)")
+	cmd.Flags().StringVar(&clientPattern, "client-pattern", "", "HTTP client pattern: rest (default), proxy-envelope (wraps requests in POST envelope)")
 
 	return cmd
 }
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 98c8e12e..c0f13c5f 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -117,6 +117,56 @@ func (l *adaptiveLimiter) Rate() float64 {
 	return l.rate
 }
 
+{{if eq .ClientPattern "proxy-envelope"}}
+// proxyEnvelope is the JSON wrapper sent to the proxy endpoint.
+type proxyEnvelope struct {
+	Service string `json:"service"`
+	Method  string `json:"method"`
+	Path    string `json:"path"`
+	Body    any    `json:"body,omitempty"`
+}
+
+// serviceForPath determines the proxy service name from the API path.
+func serviceForPath(path string) string {
+{{- if .ProxyRoutes}}
+	// Route by longest matching prefix
+	bestMatch := ""
+	bestService := ""
+	for prefix, svc := range map[string]string{
+	{{- range $prefix, $svc := .ProxyRoutes}}
+		"{{$prefix}}": "{{$svc}}",
+	{{- end}}
+	} {
+		if strings.HasPrefix(path, prefix) && len(prefix) > len(bestMatch) {
+			bestMatch = prefix
+			bestService = svc
+		}
+	}
+	if bestService != "" {
+		return bestService
+	}
+{{- end}}
+	return "{{.Name}}" // default: API name slug
+}
+
+// buildProxyPath appends query params into the path string for the envelope.
+func buildProxyPath(path string, params map[string]string) string {
+	if len(params) == 0 {
+		return path
+	}
+	parts := make([]string, 0, len(params))
+	for k, v := range params {
+		if v != "" {
+			parts = append(parts, k+"="+v)
+		}
+	}
+	if len(parts) == 0 {
+		return path
+	}
+	return path + "?" + strings.Join(parts, "&")
+}
+{{end}}
+
 // APIError carries HTTP status information for structured exit codes.
 type APIError struct {
 	Method     string
@@ -205,7 +255,12 @@ func (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {
 }
 
 func (c *Client) do(method, path string, params map[string]string, body any) (json.RawMessage, int, error) {
-	url := c.BaseURL + path
+{{- if eq .ClientPattern "proxy-envelope"}}
+	// Proxy-envelope: all requests are POST'd to BaseURL with a JSON envelope
+	targetURL := c.BaseURL
+{{- else}}
+	targetURL := c.BaseURL + path
+{{- end}}
 
 	var bodyBytes []byte
 	if body != nil {
@@ -218,7 +273,7 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
 
 	// Build the request for dry-run display or actual execution
 	if c.DryRun {
-		return c.dryRun(method, url, params, bodyBytes)
+		return c.dryRun(method, targetURL, path, params, bodyBytes)
 	}
 
 	const maxRetries = 3
@@ -228,15 +283,50 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
 		// Proactive rate limiting — wait before sending
 		c.limiter.Wait()
 
+{{- if eq .ClientPattern "proxy-envelope"}}
+		// Build the proxy envelope
+		envelope := proxyEnvelope{
+			Service: serviceForPath(path),
+			Method:  method,
+			Path:    buildProxyPath(path, params),
+		}
+		if bodyBytes != nil {
+			envelope.Body = json.RawMessage(bodyBytes)
+		}
+		envelopeBytes, err := json.Marshal(envelope)
+		if err != nil {
+			return nil, 0, fmt.Errorf("marshaling proxy envelope: %w", err)
+		}
+
+		req, err := http.NewRequest("POST", targetURL, strings.NewReader(string(envelopeBytes)))
+		if err != nil {
+			return nil, 0, fmt.Errorf("creating request: %w", err)
+		}
+		req.Header.Set("Content-Type", "application/json")
+{{- else}}
 		var bodyReader io.Reader
 		if bodyBytes != nil {
 			bodyReader = strings.NewReader(string(bodyBytes))
 		}
 
-		req, err := http.NewRequest(method, url, bodyReader)
+		req, err := http.NewRequest(method, targetURL, bodyReader)
 		if err != nil {
 			return nil, 0, fmt.Errorf("creating request: %w", err)
 		}
+		if bodyBytes != nil {
+			req.Header.Set("Content-Type", "application/json")
+		}
+
+		if params != nil {
+			q := req.URL.Query()
+			for k, v := range params {
+				if v != "" {
+					q.Set(k, v)
+				}
+			}
+			req.URL.RawQuery = q.Encode()
+		}
+{{- end}}
 
 		authHeader, err := c.authHeader()
 		if err != nil {
@@ -252,21 +342,8 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
 			req.Header.Set("{{if .Auth.Header}}{{.Auth.Header}}{{else}}Authorization{{end}}", authHeader)
 {{- end}}
 		}
-		if bodyBytes != nil {
-			req.Header.Set("Content-Type", "application/json")
-		}
 		req.Header.Set("User-Agent", "{{.Name}}-pp-cli/{{.Version}}")
 
-		if params != nil {
-			q := req.URL.Query()
-			for k, v := range params {
-				if v != "" {
-					q.Set(k, v)
-				}
-			}
-			req.URL.RawQuery = q.Encode()
-		}
-
 		resp, err := c.HTTPClient.Do(req)
 		if err != nil {
 			lastErr = fmt.Errorf("%s %s: %w", method, path, err)
@@ -318,8 +395,22 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
 	return nil, 0, lastErr
 }
 
-func (c *Client) dryRun(method, url string, params map[string]string, body []byte) (json.RawMessage, int, error) {
-	fmt.Fprintf(os.Stderr, "%s %s\n", method, url)
+func (c *Client) dryRun(method, targetURL, path string, params map[string]string, body []byte) (json.RawMessage, int, error) {
+{{- if eq .ClientPattern "proxy-envelope"}}
+	// Show the proxy envelope that would be sent
+	envelope := proxyEnvelope{
+		Service: serviceForPath(path),
+		Method:  method,
+		Path:    buildProxyPath(path, params),
+	}
+	if body != nil {
+		envelope.Body = json.RawMessage(body)
+	}
+	fmt.Fprintf(os.Stderr, "POST %s\n", targetURL)
+	envelopeJSON, _ := json.MarshalIndent(envelope, "  ", "  ")
+	fmt.Fprintf(os.Stderr, "  Envelope:\n  %s\n", string(envelopeJSON))
+{{- else}}
+	fmt.Fprintf(os.Stderr, "%s %s\n", method, targetURL)
 	if params != nil {
 		for k, v := range params {
 			if v != "" {
@@ -327,6 +418,16 @@ func (c *Client) dryRun(method, url string, params map[string]string, body []byt
 			}
 		}
 	}
+	if body != nil {
+		var pretty json.RawMessage
+		if json.Unmarshal(body, &pretty) == nil {
+			enc := json.NewEncoder(os.Stderr)
+			enc.SetIndent("  ", "  ")
+			fmt.Fprintf(os.Stderr, "  Body:\n")
+			enc.Encode(pretty)
+		}
+	}
+{{- end}}
 	authHeader, err := c.authHeader()
 	if err != nil {
 		return nil, 0, err
@@ -339,15 +440,6 @@ func (c *Client) dryRun(method, url string, params map[string]string, body []byt
 		}
 		fmt.Fprintf(os.Stderr, "  Authorization: %s\n", auth)
 	}
-	if body != nil {
-		var pretty json.RawMessage
-		if json.Unmarshal(body, &pretty) == nil {
-			enc := json.NewEncoder(os.Stderr)
-			enc.SetIndent("  ", "  ")
-			fmt.Fprintf(os.Stderr, "  Body:\n")
-			enc.Encode(pretty)
-		}
-	}
 	fmt.Fprintf(os.Stderr, "\n(dry run - no request sent)\n")
 	return json.RawMessage(`{"dry_run": true}`), 0, nil
 }
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 74ab49d7..74731c38 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -150,6 +150,21 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
 		version = strings.TrimSpace(doc.Info.Version)
 	}
 
+	// Extract x-proxy-routes extension for proxy-envelope client pattern
+	var proxyRoutes map[string]string
+	if doc.Info != nil && doc.Info.Extensions != nil {
+		if raw, ok := doc.Info.Extensions["x-proxy-routes"]; ok {
+			if m, ok := raw.(map[string]interface{}); ok {
+				proxyRoutes = make(map[string]string, len(m))
+				for k, v := range m {
+					if s, ok := v.(string); ok {
+						proxyRoutes[k] = s
+					}
+				}
+			}
+		}
+	}
+
 	baseURL := ""
 	basePath := ""
 	if len(doc.Servers) > 0 && doc.Servers[0] != nil {
@@ -198,6 +213,7 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
 		Version:     version,
 		BaseURL:     baseURL,
 		BasePath:    basePath,
+		ProxyRoutes: proxyRoutes,
 		Auth:        mapAuth(doc, name),
 		Config: spec.ConfigSpec{
 			Format: "toml",
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index d14fbfbf..bda4a3b1 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -8,17 +8,19 @@ import (
 )
 
 type APISpec struct {
-	Name        string              `yaml:"name"`
-	Description string              `yaml:"description"`
-	Version     string              `yaml:"version"`
-	BaseURL     string              `yaml:"base_url"`
-	BasePath    string              `yaml:"base_path,omitempty"`
-	Owner       string              `yaml:"owner,omitempty"`       // GitHub owner for import paths and Homebrew tap
-	SpecSource  string              `yaml:"spec_source,omitempty"` // official, community, sniffed, docs — affects generated client defaults
-	Auth        AuthConfig          `yaml:"auth"`
-	Config      ConfigSpec          `yaml:"config"`
-	Resources   map[string]Resource `yaml:"resources"`
-	Types       map[string]TypeDef  `yaml:"types"`
+	Name          string              `yaml:"name"`
+	Description   string              `yaml:"description"`
+	Version       string              `yaml:"version"`
+	BaseURL       string              `yaml:"base_url"`
+	BasePath      string              `yaml:"base_path,omitempty"`
+	Owner         string              `yaml:"owner,omitempty"`          // GitHub owner for import paths and Homebrew tap
+	SpecSource    string              `yaml:"spec_source,omitempty"`    // official, community, sniffed, docs — affects generated client defaults
+	ClientPattern string              `yaml:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
+	ProxyRoutes   map[string]string   `yaml:"proxy_routes,omitempty"`   // path prefix → service name for proxy-envelope routing
+	Auth          AuthConfig          `yaml:"auth"`
+	Config        ConfigSpec          `yaml:"config"`
+	Resources     map[string]Resource `yaml:"resources"`
+	Types         map[string]TypeDef  `yaml:"types"`
 }
 
 type AuthConfig struct {
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index d4828ad5..bcd9ccde 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -413,6 +413,26 @@ When making API calls during sniff (browser-use eval, fetch, or direct HTTP requ
 
 Track the current delay mentally. Report the effective rate when summarizing sniff results: "Sniffed N endpoints at ~X req/s effective rate."
 
+#### Proxy Pattern Detection
+
+After capturing API traffic, check if the API uses a proxy-envelope pattern:
+
+1. **Same-URL signal**: If all captured XHR/fetch URLs resolve to the same path (e.g., all calls go to `_api/ws/proxy`), the API likely uses a proxy pattern
+2. **Envelope signal**: If intercepted request bodies contain `service`, `method`, and `path` keys (or similar routing fields), it's a proxy-envelope
+3. **Confirmation**: If both signals are present, classify as `client_pattern: proxy-envelope`
+
+When a proxy pattern is detected:
+- Note the proxy URL (it becomes the spec's `servers[0].url`)
+- Extract the service routing from request bodies — build an `x-proxy-routes` map of path prefixes to service names
+- Write `x-proxy-routes` into the generated spec's `info` extensions:
+  ```yaml
+  info:
+    x-proxy-routes:
+      /v1/api/: publishing
+      /search-all: search
+  ```
+- Pass `--client-pattern proxy-envelope` to the generate command in Phase 2
+
 #### Step 1: Detect capture tools
 
 Check which browser automation tools are available:
@@ -775,6 +795,8 @@ printing-press generate \
   --output "$PRESS_LIBRARY/<api>-pp-cli" \
   --spec-source sniffed \
   --force --lenient --validate
+# If proxy pattern was detected during sniff, add:
+#   --client-pattern proxy-envelope
 ```
 
 Sniff-only (no original spec, sniff was the primary source):
@@ -785,6 +807,8 @@ printing-press generate \
   --output "$PRESS_LIBRARY/<api>-pp-cli" \
   --spec-source sniffed \
   --force --lenient --validate
+# If proxy pattern was detected during sniff, add:
+#   --client-pattern proxy-envelope
 ```
 
 Docs-only:

← 244c4845 feat(cli): use local module path at generation, rewrite at p  ·  back to Cli Printing Press  ·  fix(cli): add smart-default table output for POST endpoints 797049f5 →